--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Commit e5923bc06f06dea8e9c8c068bd82b03cb0a529f8
Parents : cad1c8c
Author : Mark Qvist <bc7291552be7a58f361522990465165c>
Signature : T66BB85Valid, signed by author
Date : 2026-07-12T17:22:06+02:00
Refactored map view. Added interface discovery.
Changes
12 files changed, 1628 insertions(+), 936 deletions(-)
Diff
diff --git a/sbapp/main.py b/sbapp/main.py
index fc3fcce1..74c66ca4 100644
--- a/sbapp/main.py
+++ b/sbapp/main.py
@@ -439,10 +439,6 @@ else:
from kivymd.uix.button import MDRectangleFlatButton
from kivymd.uix.dialog import MDDialog
from kivymd.color_definitions import colors
- from sideband.sense import Telemeter
- from mapview import CustomMapMarker
- from mapview.mbtsource import MBTilesMapSource
- from mapview.source import MapSource
from kivy.utils import escape_markup
import webbrowser
import kivy.core.image
@@ -459,6 +455,7 @@ else:
from ui.voice import Voice
from ui.guide import Guide
from ui.keys import Keys
+ from ui.map import Map
from ui.hardware import Hardware
from ui.objectdetails import ObjectDetails
from ui.announces import Announces
@@ -494,6 +491,7 @@ else:
from .ui.utilities import Utilities
from .ui.voice import Voice
from .ui.guide import Guide
+ from .ui.map import Map
from .ui.keys import Keys
from .ui.hardware import Hardware
from .ui.objectdetails import ObjectDetails
@@ -569,13 +567,8 @@ class SidebandApp(MDApp):
self.include_objects = False
self.messages_view = None
self.map = None
- self.map_layer = None
- self.map_screen = None
self.telemetry_screen = None
self.connectivity_screen = None
- self.map_cache = self.sideband.map_cache
- self.offline_source = None
- self.map_settings_screen = None
self.object_details_screen = None
self.sync_dialog = None
self.settings_ready = False
@@ -605,6 +598,8 @@ class SidebandApp(MDApp):
self.compat_error_dialog = None
self.rec_dialog_is_open = True
self.key_ptt_down = False
+ self.own_appearance_changed = False
+ self.if_discovery = None
Window.softinput_mode = "below_target"
self.window_state = app_init_window_state
@@ -1243,15 +1238,12 @@ class SidebandApp(MDApp):
else:
Environment = autoclass('android.os.Environment')
- if Environment.isExternalStorageManager():
- storage_permissions_ok = True
+ if Environment.isExternalStorageManager(): storage_permissions_ok = True
else:
ok_button = MDRectangleFlatButton(text="OK",font_size=dp(18))
- dialog = MDDialog(
- title="Storage Permission",
- text="Sideband needs external storage permission to read offline map files.\n\nOn this Android version, the Manage All Files permission is needed, since normal external storage permission is no longer supported.\n\nSideband will only ever read and write to files you select, and does not read any other data from your system.",
- buttons=[ ok_button ],
- )
+ dialog = MDDialog(title="Storage Permission",
+ text="Sideband needs external storage permission to read external files.\n\nOn this Android version, the Manage All Files permission is needed, since normal external storage permission is no longer supported.\n\nSideband will only ever read and write to files you select, and does not read any other data from your system.",
+ buttons=[ ok_button ])
def dl_ok(s):
dialog.dismiss()
self.request_storage_permission()
@@ -1474,6 +1466,7 @@ class SidebandApp(MDApp):
self.guide_view = None
self.keys_view = None
self.hardware_view = None
+ self.map_view = None
if RNS.vendor.platformutils.is_android():
ActivityInfo = autoclass('android.content.pm.ActivityInfo')
@@ -1603,14 +1596,14 @@ class SidebandApp(MDApp):
self.announces_view.update()
elif self.root.ids.screen_manager.current == "map_screen":
- if self.map_screen and hasattr(self.map_screen.ids.map_layout, "map") and self.map_screen.ids.map_layout.map != None:
- self.sideband.config["map_lat"] = self.map_screen.ids.map_layout.map.lat
- self.sideband.config["map_lon"] = self.map_screen.ids.map_layout.map.lon
- self.sideband.config["map_zoom"] = self.map_screen.ids.map_layout.map.zoom
+ if self.map_view and hasattr(self.map_view.screen.ids.map_layout, "map") and self.map_view.screen.ids.map_layout.map != None:
+ self.sideband.config["map_lat"] = self.map_view.screen.ids.map_layout.map.lat
+ self.sideband.config["map_lon"] = self.map_view.screen.ids.map_layout.map.lon
+ self.sideband.config["map_zoom"] = self.map_view.screen.ids.map_layout.map.zoom
self.last_telemetry_received = self.sideband.getstate("app.flags.last_telemetry", allow_cache=True) or 0
if self.last_telemetry_received > self.last_map_update:
- self.map_update_markers()
+ self.map_view.update_markers()
if self.sideband.getstate("app.flags.new_conversations", allow_cache=True):
if self.conversations_view != None:
@@ -1747,12 +1740,12 @@ class SidebandApp(MDApp):
else:
nav_mod = 1.0
- if keycode == 79 or text == "d" or text == "l": self.map_nav_right(modifier=nav_mod)
- if keycode == 80 or text == "a" or text == "h": self.map_nav_left(modifier=nav_mod)
- if keycode == 81 or text == "s" or text == "j": self.map_nav_down(modifier=nav_mod)
- if keycode == 82 or text == "w" or text == "k": self.map_nav_up(modifier=nav_mod)
- if text == "q" or text == "-": self.map_nav_zoom_out(modifier=nav_mod)
- if text == "e" or text == "+": self.map_nav_zoom_in(modifier=nav_mod)
+ if keycode == 79 or text == "d" or text == "l": self.map_view.nav_right(modifier=nav_mod)
+ if keycode == 80 or text == "a" or text == "h": self.map_view.nav_left(modifier=nav_mod)
+ if keycode == 81 or text == "s" or text == "j": self.map_view.nav_down(modifier=nav_mod)
+ if keycode == 82 or text == "w" or text == "k": self.map_view.nav_up(modifier=nav_mod)
+ if text == "q" or text == "-": self.map_view.nav_zoom_out(modifier=nav_mod)
+ if text == "e" or text == "+": self.map_view.nav_zoom_in(modifier=nav_mod)
if True or self.root.ids.screen_manager.current == "conversations_screen":
if len(modifiers) > 0 and "ctrl" in modifiers:
@@ -1816,7 +1809,7 @@ class SidebandApp(MDApp):
if self.root.ids.screen_manager.current == "messages_screen":
self.message_propagation_action(self)
elif self.root.ids.screen_manager.current == "map_screen":
- self.map_layers_action()
+ self.map_view.layers_action()
else:
self.announces_action(self)
@@ -1832,7 +1825,7 @@ class SidebandApp(MDApp):
if text == "p":
if self.root.ids.screen_manager.current == "map_screen":
- self.map_settings_action()
+ self.map_view.settings_action()
else:
self.settings_action(self)
@@ -2304,8 +2297,16 @@ class SidebandApp(MDApp):
context_dest = self.messages_view.ids.messages_scrollview.active_conversation
self.map_show_peer_location(context_dest)
if self.root.ids.screen_manager.current == "object_details_screen":
- context_dest = self.object_details_screen.object_hash
- self.map_show_peer_location(context_dest)
+ if not self.object_details_screen.viewing_interface:
+ context_dest = self.object_details_screen.object_hash
+ self.map_show_peer_location(context_dest)
+
+ else:
+ lat = self.object_details_screen.interface_lat
+ lon = self.object_details_screen.interface_lon
+ if lat and lon:
+ self.map_action()
+ self.map_show({"latitude": lat, "longitude": lon})
def peer_show_telemetry_action(self, sender):
if self.root.ids.screen_manager.current == "messages_screen":
@@ -3876,6 +3877,7 @@ class SidebandApp(MDApp):
self.widget_hide(self.connectivity_screen.ids.connectivity_modem_fields)
self.widget_hide(self.connectivity_screen.ids.connectivity_transport_label)
self.widget_hide(self.connectivity_screen.ids.connectivity_enable_transport)
+ self.widget_hide(self.connectivity_screen.ids.connectivity_discover_interfaces)
self.widget_hide(self.connectivity_screen.ids.connectivity_obfuscate_hops)
self.widget_hide(self.connectivity_screen.ids.connectivity_serial_label)
self.widget_hide(self.connectivity_screen.ids.connectivity_use_serial)
@@ -3926,6 +3928,7 @@ class SidebandApp(MDApp):
pass
def save_connectivity(sender=None, event=None):
+ self.sideband.config["discover_interfaces"] = self.connectivity_screen.ids.connectivity_discover_interfaces.active
self.sideband.config["connect_transport"] = self.connectivity_screen.ids.connectivity_enable_transport.active
self.sideband.config["connect_obfuscate_hops"] = self.connectivity_screen.ids.connectivity_obfuscate_hops.active
self.sideband.config["connect_share_instance"] = self.connectivity_screen.ids.connectivity_share_instance.active
@@ -4103,6 +4106,9 @@ class SidebandApp(MDApp):
self.connectivity_screen.ids.connectivity_obfuscate_hops.active = self.sideband.config["connect_obfuscate_hops"]
self.connectivity_screen.ids.connectivity_obfuscate_hops.bind(active=save_connectivity)
+ self.connectivity_screen.ids.connectivity_discover_interfaces.active = self.sideband.config["discover_interfaces"]
+ self.connectivity_screen.ids.connectivity_discover_interfaces.bind(active=save_connectivity)
+
self.connectivity_screen.ids.connectivity_share_instance.active = self.sideband.config["connect_share_instance"]
self.connectivity_screen.ids.connectivity_share_instance.bind(active=save_connectivity)
@@ -4825,14 +4831,22 @@ class SidebandApp(MDApp):
def converse_from_telemetry(self, sender=None):
if self.object_details_screen != None:
context_dest = self.object_details_screen.object_hash
- if not self.object_details_screen.object_hash == self.sideband.lxmf_destination.hash:
- if self.sideband.has_conversation(context_dest):
- pass
- else:
- self.sideband.create_conversation(context_dest)
- self.sideband.setstate("app.flags.new_conversations", True)
+ if len(context_dest) == RNS.Reticulum.TRUNCATED_HASHLENGTH//8:
+ if not self.object_details_screen.object_hash == self.sideband.lxmf_destination.hash:
+ if self.sideband.has_conversation(context_dest):
+ pass
+ else:
+ self.sideband.create_conversation(context_dest)
+ self.sideband.setstate("app.flags.new_conversations", True)
+
+ self.open_conversation(context_dest)
- self.open_conversation(context_dest)
+ else:
+ lat = self.object_details_screen.interface_lat
+ lon = self.object_details_screen.interface_lon
+ if lat and lon:
+ self.map_action()
+ self.map_show({"latitude": lat, "longitude": lon})
def telemetry_send_update(self, sender=None):
if not hasattr(self, "telemetry_info_dialog") or self.telemetry_info_dialog == None:
@@ -4925,257 +4939,8 @@ class SidebandApp(MDApp):
### Map Screen
######################################
- def map_fm_got_path(self, path):
- self.map_fm_exited()
- try:
- source = MBTilesMapSource(path)
- self.map_update_source()
- self.sideband.config["map_storage_file"] = path
- self.sideband.config["map_storage_path"] = str(pathlib.Path(path).parent.resolve())
- self.sideband.save_configuration()
-
- if RNS.vendor.platformutils.is_android():
- toast("Using \""+os.path.basename(path)+"\" as offline map")
- else:
- ok_button = MDRectangleFlatButton(text="OK",font_size=dp(18))
- ate_dialog = MDDialog(
- title="Map Set",
- text="Using \""+os.path.basename(path)+"\" as offline map",
- buttons=[ ok_button ],
- )
- ok_button.bind(on_release=ate_dialog.dismiss)
- ate_dialog.open()
-
- except Exception as e:
- RNS.log(f"Error while loading map \"{path}\": "+str(e), RNS.LOG_ERROR)
- if RNS.vendor.platformutils.get_platform() == "android":
- toast("Could not load map \""+os.path.basename(path)+"\"")
- else:
- ok_button = MDRectangleFlatButton(text="OK",font_size=dp(18))
- map_dialog = MDDialog(
- title="Map Error",
- text="The specified map file could not be loaded. Make sure the selected file is an MBTiles map in raster format. Vector maps are currently not supported.",
- buttons=[ ok_button ],
- )
- ok_button.bind(on_release=map_dialog.dismiss)
- map_dialog.open()
- self.sideband.config["map_storage_file"] = None
- self.sideband.config["map_use_offline"] = False
- self.sideband.config["map_use_online"] = True
- self.map_settings_load_states()
- self.map_update_source()
-
- def map_fm_exited(self, *args):
- self.manager_open = False
- self.file_manager.close()
- self.map_update_source()
-
- def map_select_file_action(self, sender=None):
- perm_ok = False
- if self.sideband.config["map_storage_path"] == None:
- if RNS.vendor.platformutils.is_android():
- perm_ok = self.check_storage_permission()
-
- if self.sideband.config["map_storage_external"]:
- path = secondary_external_storage_path()
- if path == None: path = primary_external_storage_path()
- else:
- path = primary_external_storage_path()
-
- else:
- perm_ok = True
- if self.sideband.config["map_storage_external"]:
- path = "/"
- else:
- path = os.path.expanduser("~")
- else:
- perm_ok = True
- path = self.sideband.config["map_storage_path"]
-
- if perm_ok and path != None:
- try:
- self.file_manager = MDFileManager(
- exit_manager=self.map_fm_exited,
- select_path=self.map_fm_got_path,
- )
- self.file_manager.ext = [".mbtiles"]
- self.file_manager.show(path)
-
- except Exception as e:
- self.sideband.config["map_storage_path"] = None
- self.sideband.save_configuration()
- if RNS.vendor.platformutils.is_android():
- toast("Error reading directory, check permissions!")
- else:
- ok_button = MDRectangleFlatButton(text="OK",font_size=dp(18))
- ate_dialog = MDDialog(
- title="Error",
- text="Could not read directory, check permissions!",
- buttons=[ ok_button ],
- )
- ok_button.bind(on_release=ate_dialog.dismiss)
- ate_dialog.open()
-
- else:
- self.sideband.config["map_storage_path"] = None
- self.sideband.save_configuration()
- if RNS.vendor.platformutils.is_android():
- toast("No file access, check permissions!")
- else:
- ok_button = MDRectangleFlatButton(text="OK",font_size=dp(18))
- ate_dialog = MDDialog(
- title="Error",
- text="No file access, check permissions!",
- buttons=[ ok_button ],
- )
- ok_button.bind(on_release=ate_dialog.dismiss)
- ate_dialog.open()
-
- def map_get_offline_source(self):
- if self.offline_source != None:
- return self.offline_source
- else:
- try:
- current_map_path = self.sideband.config["map_storage_file"]
- if current_map_path == None:
- raise ValueError("Map path cannot be None")
- source = MBTilesMapSource(current_map_path, cache_dir=self.map_cache)
- self.offline_source = source
- return self.offline_source
-
- except Exception as e:
- RNS.log(f"Error while loading map from \"{current_map_path}\": "+str(e))
- self.sideband.config["map_storage_file"] = None
- self.sideband.config["map_use_offline"] = False
- self.sideband.config["map_use_online"] = True
- self.sideband.save_configuration()
- self.map_settings_load_states()
-
- return None
-
- def map_get_source(self):
- source = None
- if self.sideband.config["map_use_offline"]:
- source = self.map_get_offline_source()
-
- if source == None:
- source = MapSource.from_provider("osm", cache_dir=self.map_cache, quad_key=False)
-
- return source
-
- def map_update_source(self, source=None):
- ns = source or self.map_get_source()
- if self.map != None:
-
- if source != None:
- maxz = source.max_zoom
- minz = source.min_zoom
- if self.map.zoom > maxz:
- mz = maxz; px, py = self.map_get_zoom_center(); self.map.set_zoom_at(mz, px, py)
-
- if self.map.zoom < minz:
- mz = minz; px, py = self.map_get_zoom_center(); self.map.set_zoom_at(mz, px, py)
-
- m = self.map
- nlat = self.map.lat
- nlon = self.map.lon
- if nlat < -89: nlat = -89
- if nlat > 89: nlat = 89
- if nlon < -179: nlon = -179
- if nlon > 179: nlon = 179
- self.map.center_on(nlat,nlon)
-
-
- self.map.map_source = ns
-
- def map_layers_action(self, sender=None):
- try:
- ml = self.map_layer
- layers = []
- if self.sideband.config["map_use_offline"]:
- layers.append("offline")
-
- if self.sideband.config["map_use_online"]:
- layers.append("osm")
- layers.append("ve")
-
- if ml == None: ml = layers[0]
-
- if not ml in layers:
- ml = layers[0]
-
- mli = layers.index(ml)
- mli = (mli+1)%len(layers)
- ml = layers[mli]
-
- source = None
- if ml == "offline": source = self.map_get_offline_source()
- if ml == "osm": source = MapSource.from_provider("osm", cache_dir=self.map_cache, quad_key=False)
- if ml == "ve": source = MapSource.from_provider("ve", cache_dir=self.map_cache, quad_key=True)
-
- if source != None:
- self.map_layer = ml
- self.map_update_source(source)
- except Exception as e:
- RNS.log("Error while switching map layer: "+str(e), RNS.LOG_ERROR)
-
- map_nav_divisor = 12
- map_nav_zoom = 0.25
- def map_nav_left(self, sender=None, modifier=1.0):
- if self.map != None:
- bb = self.map.get_bbox()
- lat_span = abs(bb[0] - bb[2])
- lon_span = abs(bb[1] - bb[3])
- span = min(lat_span, lon_span)
- delta = (-span/self.map_nav_divisor)*modifier
- self.map.center_on(self.map.lat, self.map.lon+delta)
-
- def map_nav_right(self, sender=None, modifier=1.0):
- if self.map != None:
- bb = self.map.get_bbox()
- lat_span = abs(bb[0] - bb[2])
- lon_span = abs(bb[1] - bb[3])
- span = min(lat_span, lon_span)
- delta = (span/self.map_nav_divisor)*modifier
- self.map.center_on(self.map.lat, self.map.lon+delta)
-
- def map_nav_up(self, sender=None, modifier=1.0):
- if self.map != None:
- bb = self.map.get_bbox()
- lat_span = abs(bb[0] - bb[2])
- lon_span = abs(bb[1] - bb[3])
- span = min(lat_span, lon_span)
- delta = (span/self.map_nav_divisor)*modifier
- self.map.center_on(self.map.lat+delta, self.map.lon)
-
- def map_nav_down(self, sender=None, modifier=1.0):
- if self.map != None:
- bb = self.map.get_bbox()
- lat_span = abs(bb[0] - bb[2])
- lon_span = abs(bb[1] - bb[3])
- span = min(lat_span, lon_span)
- delta = (-span/self.map_nav_divisor)*modifier
- self.map.center_on(self.map.lat+delta, self.map.lon)
-
- def map_get_zoom_center(self):
- bb = self.map.get_bbox()
- slat = (bb[2]-bb[0])/2; slon = (bb[3]-bb[1])/2
- zlat = bb[0]+slat; zlon = bb[1]+slon
- return self.map.get_window_xy_from(zlat, zlon, self.map.zoom)
-
- def map_nav_zoom_out(self, sender=None, modifier=1.0):
- if self.map != None:
- zd = -self.map_nav_zoom*modifier
- if self.map.zoom+zd > self.map.map_source.min_zoom:
- px, py = self.map_get_zoom_center()
- self.map.animated_diff_scale_at(zd, px, py)
-
- def map_nav_zoom_in(self, sender=None, modifier=1.0):
- if self.map != None:
- zd = self.map_nav_zoom*modifier
- if self.map.zoom+zd < self.map.map_source.max_zoom or self.map.scale < 3.0:
- px, py = self.map_get_zoom_center()
- self.map.animated_diff_scale_at(zd, px, py)
+ def init_map_view(self, sender=None):
+ if not self.map_view: self.map_view = Map(self)
def map_action(self, sender=None, direction="left"):
if self.root.ids.screen_manager.has_screen("map_screen"):
@@ -5183,36 +4948,11 @@ class SidebandApp(MDApp):
else:
self.loader_action(direction=direction)
def final(dt):
- self.map_init()
- def o(dt):
- self.map_open(sender=sender, no_transition=True)
+ self.init_map_view()
+ def o(dt): self.map_open(sender=sender, no_transition=True)
Clock.schedule_once(o, ll_ot)
Clock.schedule_once(final, ll_ft)
- def map_init(self):
- if not self.root.ids.screen_manager.has_screen("map_screen"):
- msource = self.map_get_source()
- mzoom = self.sideband.config["map_zoom"]
- mlat = self.sideband.config["map_lat"]; mlon = self.sideband.config["map_lon"]
- if mzoom > msource.max_zoom: mzoom = msource.max_zoom
- if mzoom < msource.min_zoom: mzoom = msource.min_zoom
- if mlat < -89: mlat = -89
- if mlat > 89: mlat = 89
- if mlon < -179: mlon = -179
- if mlon > 179: mlon = 179
-
- self.map_screen = Builder.load_string(layout_map_screen)
- self.map_screen.app = self
- self.root.ids.screen_manager.add_widget(self.map_screen)
-
- from mapview import MapView
- mapview = MapView(map_source=msource, zoom=mzoom, lat=mlat, lon=mlon)
- mapview.snap_to_zoom = False
- mapview.double_tap_zoom = True
- self.map = mapview
- self.map_screen.ids.map_layout.map = mapview
- self.map_screen.ids.map_layout.add_widget(self.map_screen.ids.map_layout.map)
-
def map_open(self, sender=None, direction="left", no_transition=False):
if no_transition:
self.root.ids.screen_manager.transition = self.no_transition
@@ -5225,125 +4965,15 @@ class SidebandApp(MDApp):
self.root.ids.nav_drawer.set_state("closed")
self.sideband.setstate("app.displaying", self.root.ids.screen_manager.current)
- if not hasattr(self, "map_markers") or self.map_markers == None:
- self.map_markers = {}
-
- def am_job(dt):
- self.map_update_markers()
+ def am_job(dt): self.map_view.update_markers()
Clock.schedule_once(am_job, 0.15)
if no_transition:
self.root.ids.screen_manager.transition = self.slide_transition
- def map_settings_load_states(self):
- if self.map_settings_screen != None:
- self.map_settings_screen.ids.map_use_online.active = self.sideband.config["map_use_online"]
- self.map_settings_screen.ids.map_use_offline.active = self.sideband.config["map_use_offline"]
- self.map_settings_screen.ids.map_storage_external.active = self.sideband.config["map_storage_external"]
-
- def map_settings_init(self):
- self.map_settings_load_states()
- def map_settings_save(sender=None, event=None):
- self.sideband.config["map_storage_external"] = self.map_settings_screen.ids.map_storage_external.active
- self.sideband.config["map_use_online"] = self.map_settings_screen.ids.map_use_online.active
- self.sideband.config["map_use_offline"] = self.map_settings_screen.ids.map_use_offline.active
- self.sideband.save_configuration()
-
- def external_toggle(sender=None, event=None):
- self.sideband.config["map_storage_path"] = None
- map_settings_save()
-
- def offline_toggle(sender=None, event=None):
- if self.map_settings_screen.ids.map_use_offline.active:
- # self.map_settings_screen.ids.map_use_online.active = False
- if self.sideband.config["map_storage_file"] == None:
- self.map_select_file_action()
- else:
- self.map_settings_screen.ids.map_use_online.active = True
- map_settings_save(); self.map_update_source()
-
- def online_toggle(sender=None, event=None):
- if self.map_settings_screen.ids.map_use_online.active:
- # self.map_settings_screen.ids.map_use_offline.active = False
- pass
- else:
- self.map_settings_screen.ids.map_use_offline.active = True
- map_settings_save(); self.map_update_source()
-
-
- self.map_settings_screen.ids.map_use_offline.bind(active=offline_toggle)
- self.map_settings_screen.ids.map_use_online.bind(active=online_toggle)
- self.map_settings_screen.ids.map_storage_external.bind(active=external_toggle)
-
- def map_settings_action(self, sender=None, direction="left"):
- if not self.root.ids.screen_manager.has_screen("map_settings_screen"):
- self.map_settings_screen = Builder.load_string(layout_map_settings_screen)
- self.map_settings_screen.app = self
- self.root.ids.screen_manager.add_widget(self.map_settings_screen)
- self.map_settings_screen.ids.map_config_info.text = "\n\nSideband can use map sources from the Internet, or a map source stored locally on this device in MBTiles format."
- self.map_settings_screen.ids.map_settings_scrollview.effect_cls = ScrollEffect
- self.map_settings_init()
-
- self.root.ids.screen_manager.transition.direction = direction
- self.root.ids.screen_manager.current = "map_settings_screen"
- self.root.ids.nav_drawer.set_state("closed")
- self.sideband.setstate("app.displaying", self.root.ids.screen_manager.current)
-
- def update_cache_size(dt):
- size = self.sideband.get_map_cache_size()
- size_str = RNS.prettysize(size)
- self.map_settings_screen.ids.map_cache_button.text = f"Clear {size_str} map cache"
- if size > 0.0:
- self.map_settings_screen.ids.map_cache_button.disabled = False
- else:
- self.map_settings_screen.ids.map_cache_button.disabled = True
- self.map_settings_screen.ids.map_cache_button.text = f"No data in map cache"
-
- Clock.schedule_once(update_cache_size, 0.35)
-
- def map_clear_cache(self, sender=None):
- yes_button = MDRectangleFlatButton(text="Yes",font_size=dp(18), theme_text_color="Custom", line_color=self.color_reject, text_color=self.color_reject)
- no_button = MDRectangleFlatButton(text="No",font_size=dp(18))
- dialog = MDDialog(
- title="Clear map cache?",
- buttons=[ yes_button, no_button ],
- # elevation=0,
- )
- def dl_yes(s):
- dialog.dismiss()
- self.sideband.clear_map_cache()
-
- def cb(dt):
- self.map_settings_action()
- self.map_settings_screen.ids.map_cache_button.disabled = True
- Clock.schedule_once(cb, 1.2)
-
- def dl_no(s):
- dialog.dismiss()
-
- yes_button.bind(on_release=dl_yes)
- no_button.bind(on_release=dl_no)
- dialog.open()
-
- def close_location_error_dialog(self, sender=None):
- if hasattr(self, "location_error_dialog") and self.location_error_dialog != None:
- self.location_error_dialog.dismiss()
-
- def map_object_list(self, sender):
- pass
-
def map_show(self, location, retry=0):
max_tries = 6
- if hasattr(self, "map") and self.map:
- mz = 16
- lat = location["latitude"]
- lon = location["longitude"]
- if mz > self.map.map_source.max_zoom: mz = self.map.map_source.max_zoom
- if mz < self.map.map_source.min_zoom: mz = self.map.map_source.min_zoom
- self.map.zoom = mz
- self.map.trigger_update(True)
- self.map.center_on(lat,lon)
- self.map.trigger_update(True)
+ if self.map_view: self.map_view.show(location)
else:
if retry < max_tries:
def j(dt):
@@ -5353,18 +4983,10 @@ class SidebandApp(MDApp):
def map_show_peer_location(self, context_dest):
location = self.sideband.peer_location(context_dest)
if not location:
- self.location_error_dialog = MDDialog(
- title="No Location",
- text="No recent location updates have been received from this peer. You can use the the [b]Situation Map[/b] to manually search for earlier telemetry.",
- buttons=[
- MDRectangleFlatButton(
- text="OK",
- font_size=dp(18),
- on_release=self.close_location_error_dialog
- )
- ],
- )
+ self.location_error_dialog = MDDialog(title="No Location", text="No recent location updates have been received from this peer. You can use the the [b]Situation Map[/b] to manually search for earlier telemetry.",
+ buttons=[MDRectangleFlatButton(text="OK", font_size=dp(18), on_release=self.close_location_error_dialog)])
self.location_error_dialog.open()
+
else:
self.map_action()
self.map_show(location)
@@ -5373,17 +4995,8 @@ class SidebandApp(MDApp):
self.sideband.update_telemetry()
location = self.sideband.peer_location(self.sideband.lxmf_destination.hash)
if not location:
- self.location_error_dialog = MDDialog(
- title="No Location",
- text="Your location is currently unknown. Make sure the relevant telemetry sensors and permissions have been enabled.",
- buttons=[
- MDRectangleFlatButton(
- text="OK",
- font_size=dp(18),
- on_release=self.close_location_error_dialog
- )
- ],
- )
+ self.location_error_dialog = MDDialog(title="No Location", text="Your location is currently unknown. Make sure the relevant telemetry sensors and permissions have been enabled.",
+ buttons=[MDRectangleFlatButton(text="OK", font_size=dp(18), on_release=self.close_location_error_dialog)])
self.location_error_dialog.open()
else:
self.map_action()
@@ -5402,18 +5015,41 @@ class SidebandApp(MDApp):
if hasattr(sender, "source_dest"):
self.sideband.request_latest_telemetry(from_addr=sender.source_dest)
toast("Telemetry request sent")
- except Exception as e:
- RNS.log(f"Could not request telemetry update: {e}", RNS.LOG_ERROR)
- else:
- self.object_details_action(sender)
+ except Exception as e: RNS.log(f"Could not request telemetry update: {e}", RNS.LOG_ERROR)
+ else: self.object_details_action(sender)
def map_display_own_telemetry(self, sender=None):
self.sideband.update_telemetry()
self.object_details_action(source_dest=self.sideband.lxmf_destination.hash,from_telemetry=True)
+ def map_object_list(self, sender): pass
+
+ def close_location_error_dialog(self, sender=None):
+ if hasattr(self, "location_error_dialog") and self.location_error_dialog != None:
+ self.location_error_dialog.dismiss()
+
def close_sub_map_action(self, sender=None):
self.map_action(direction="right")
+ def list_discovered_interfaces(self):
+ if not self.if_discovery: self.if_discovery = RNS.Discovery.InterfaceDiscovery(discover_interfaces=False)
+ return self.if_discovery.list_discovered_interfaces()
+
+ def iface_icon(self, iface_type):
+ if iface_type == "RNodeInterface": return "access-point-network"
+ elif iface_type == "Weave": return "access-point"
+ elif iface_type == "BackboneInterface": return "wan"
+ elif iface_type == "TCPServerInterface": return "lan-connect"
+ elif iface_type == "I2PInterface": return "lock-pattern"
+ else: return "lan-connect"
+
+ def iface_appearance(self, iface_type):
+ f = 0.8
+ return [self.iface_icon(iface_type), [255/255, 253/255, 231/255, 1], [75/255*f, 97/255*f, 108/255*f,1]]
+
+ ### Object details screen
+ ######################################
+
def object_details_action(self, sender=None, from_conv=False, from_objects=False, from_telemetry=False, source_dest=None, direction="left"):
if self.root.ids.screen_manager.has_screen("object_details_screen"):
self.object_details_open(sender=sender, from_conv=from_conv, from_objects=from_objects, from_telemetry=from_telemetry, source_dest=source_dest, direction=direction)
@@ -5465,169 +5101,6 @@ class SidebandApp(MDApp):
self.root.ids.screen_manager.transition = self.slide_transition
Clock.schedule_once(vj, 0.15)
- def map_create_marker(self, source, telemetry, appearance):
- try:
- l = telemetry["location"]
- a_icon = appearance[0]
- a_fg = appearance[1]; a_bg = appearance[2]
- marker = CustomMapMarker(lat=l["latitude"], lon=l["longitude"], icon_bg=a_bg)
- marker.app = self
- marker.source_dest = source
- marker.location_time = l["last_update"]
- marker.icon = MDMapIconButton(
- icon=a_icon, icon_color=a_fg,
- md_bg_color=a_bg, theme_icon_color="Custom",
- icon_size=dp(32),
- on_release=self.map_display_telemetry,
- )
- marker.icon._default_icon_pad = dp(16)
- marker.icon.source_dest = marker.source_dest
- marker.add_widget(marker.icon)
-
- ########
- # marker.badge = MDMapIconButton(
- # icon="network-strength-2", icon_color=[0,0,0,1],
- # md_bg_color=[1,1,1,1], theme_icon_color="Custom",
- # icon_size=dp(18),
- # )
- # marker.badge._default_icon_pad = dp(5)
- # marker.icon.add_widget(marker.badge)
- ########
-
- return marker
-
- except Exception as e:
- RNS.log("Could not create map marker for "+RNS.prettyhexrep(source)+": "+str(e), RNS.LOG_ERROR)
- return None
-
- def map_update_markers(self, sender=None):
- RNS.log("Updating map markers", RNS.LOG_DEBUG)
- earliest = time.time() - self.sideband.config["map_history_limit"]
- telemetry_entries = self.sideband.list_telemetry(after=earliest)
- own_address = self.sideband.lxmf_destination.hash
- changes = False
-
- # Add own marker if available
- retain_own = False
- own_telemetry = self.sideband.get_telemetry()
- own_appearance = [
- self.sideband.config["telemetry_icon"],
- self.sideband.config["telemetry_fg"],
- self.sideband.config["telemetry_bg"]
- ]
-
- skip_entries = []
- if self.sideband.config["telemetry_display_trusted_only"]:
- for telemetry_source in telemetry_entries:
- try:
- if not self.sideband.is_trusted(telemetry_source):
- skip_entries.append(telemetry_source)
- except:
- pass
- for skip_entry in skip_entries:
- try:
- telemetry_entries.pop(skip_entry)
- except:
- pass
-
- try:
- if own_telemetry != None and "location" in own_telemetry and own_telemetry["location"] != None and own_telemetry["location"]["latitude"] != None and own_telemetry["location"]["longitude"] != None:
- retain_own = True
-
- if not own_address in self.map_markers:
- marker = self.map_create_marker(own_address, own_telemetry, own_appearance)
- if marker != None:
- self.map_markers[own_address] = marker
- self.map_screen.ids.map_layout.map.add_marker(marker)
- changes = True
-
- else:
- marker = self.map_markers[own_address]
- o = own_telemetry["location"]
- if o["last_update"] > marker.location_time or (hasattr(self, "own_appearance_changed") and self.own_appearance_changed):
- marker.location_time = o["last_update"]
- marker.lat = o["latitude"]
- marker.lon = o["longitude"]
- marker.icon.icon = own_appearance[0]
- marker.icon.icon_color = own_appearance[1]
- marker.icon.md_bg_color = own_appearance[2]
- self.own_appearance_changed = False
- changes = True
-
- stale_markers = []
- for marker in self.map_markers:
- if not marker in telemetry_entries:
- if marker == own_address:
- if not retain_own:
- stale_markers.append(marker)
- else:
- stale_markers.append(marker)
-
- for marker in stale_markers:
- RNS.log("Removing stale marker: "+str(marker), RNS.LOG_DEBUG)
- try:
- to_remove = self.map_markers[marker]
- self.map_screen.ids.map_layout.map.remove_marker(to_remove)
- self.map_markers.pop(marker)
- changes = True
- except Exception as e:
- RNS.log("Error while removing map marker: "+str(e), RNS.LOG_ERROR)
-
- except Exception as e:
- RNS.log("Error while updating own map marker: "+str(e), RNS.LOG_ERROR)
-
- for telemetry_source in telemetry_entries:
- try:
- skip = False
- if telemetry_source == own_address:
- skip = True
- elif telemetry_source in self.map_markers:
- marker = self.map_markers[telemetry_source]
- newest_timestamp = telemetry_entries[telemetry_source][0][0]
- if newest_timestamp <= marker.location_time:
- skip = True
-
- latest_viewable = None
- if not skip:
- for telemetry_entry in sorted(telemetry_entries[telemetry_source], key=lambda t: t[0], reverse=True):
- telemetry_timestamp = telemetry_entry[0]
- telemetry_data = telemetry_entry[1]
- t = Telemeter.from_packed(telemetry_data)
- if t != None:
- telemetry = t.read_all()
- if "location" in telemetry and telemetry["location"] != None and telemetry["location"]["latitude"] != None and telemetry["location"]["longitude"] != None:
- latest_viewable = telemetry
- break
- elif "connection_map" in telemetry:
- # TODO: Telemetry entries with connection map sensor types are skipped for now,
- # until a proper rendering mechanism is implemented
- break
-
- if latest_viewable != None:
- l = latest_viewable["location"]
- if not telemetry_source in self.map_markers:
- marker = self.map_create_marker(telemetry_source, latest_viewable, self.sideband.peer_appearance(telemetry_source))
- if marker != None:
- self.map_markers[telemetry_source] = marker
- self.map_screen.ids.map_layout.map.add_marker(marker)
- changes = True
- else:
- marker = self.map_markers[telemetry_source]
- marker.location_time = latest_viewable["time"]["utc"]
- marker.lat = l["latitude"]
- marker.lon = l["longitude"]
- appearance = self.sideband.peer_appearance(telemetry_source)
- marker.icon.icon = appearance[0]
- marker.icon.icon_color = appearance[1]
- marker.icon.md_bg_color = appearance[2]
- changes = True
-
- except Exception as e:
- RNS.log("Error while updating map entry for "+RNS.prettyhexrep(telemetry_source)+": "+str(e), RNS.LOG_ERROR)
-
- self.last_map_update = time.time()
- if changes:
- self.map.trigger_update(True)
### Guide screen
######################################
@@ -5717,9 +5190,6 @@ class DialogItem(OneLineIconListItem):
divider = None
icon = StringProperty()
-class MDMapIconButton(MDIconButton):
- pass
-
class UIScaling(BoxLayout):
pass
diff --git a/sbapp/mapview/clustered_marker_layer.py b/sbapp/mapview/clustered_marker_layer.py
index c114e58c..eb9f2a4e 100644
--- a/sbapp/mapview/clustered_marker_layer.py
+++ b/sbapp/mapview/clustered_marker_layer.py
@@ -3,6 +3,8 @@
Layer that support point clustering
===================================
"""
+import time
+import RNS
from math import atan, exp, floor, log, pi, sin, sqrt
from os.path import dirname, join
@@ -16,7 +18,8 @@ from kivy.properties import (
StringProperty,
)
-from mapview.view import MapLayer, MapMarker
+from mapview.view import MapLayer, MapMarker, CustomMapMarker
+from kivymd.uix.button import MDIconButton
Builder.load_string(
"""
@@ -31,7 +34,23 @@ Builder.load_string(
pos: root.pos
size: root.size
text: "{}".format(root.num_points)
- font_size: dp(18)
+ font_size: dp(20)
+
+<TelemetryClusterMarker>:
+ size_hint: None, None
+ size: list(map(dp, self.texture_size))
+ allow_stretch: True
+
+ MDTelemetryIconButton:
+ app: root.app
+ pos: [root.pos[0], root.pos[1]+dp(16)]
+ icon: root.icon
+ icon_color: root.icon_color
+ md_bg_color: root.md_bg_color
+ theme_icon_color: "Custom"
+ icon_size: dp(32)
+ _default_icon_pad: dp(16)
+ source_dest: root.source_dest
"""
)
@@ -81,16 +100,13 @@ class KDBush:
self._sort(ids, coords, node_size, 0, len(ids) - 1, 0)
def range(self, min_x, min_y, max_x, max_y):
- return self._range(
- self.ids, self.coords, min_x, min_y, max_x, max_y, self.node_size
- )
+ return self._range(self.ids, self.coords, min_x, min_y, max_x, max_y, self.node_size)
def within(self, x, y, r):
return self._within(self.ids, self.coords, x, y, r, self.node_size)
def _sort(self, ids, coords, node_size, left, right, depth):
- if right - left <= node_size:
- return
+ if right - left <= node_size: return
m = int(floor((left + right) / 2.0))
self._select(ids, coords, m, left, right, depth % 2)
self._sort(ids, coords, node_size, left, m - 1, depth + 1)
@@ -232,9 +248,10 @@ class KDBush:
class Cluster:
- def __init__(self, x, y, num_points, id, props):
+ def __init__(self, x, y, num_points, id, props, extents = None):
self.x = x
self.y = y
+ self.extents = extents
self.num_points = num_points
self.zoom = float("inf")
self.id = id
@@ -264,10 +281,91 @@ class Marker:
self.parent_id = None
self.widget = None
+ def update_props(self):
+ self.x = lngX(self.lon)
+ self.y = latY(self.lat)
+ if self.widget:
+ self.widget.lat = self.lat
+ self.widget.lon = self.lon
+ self.widget.icon = self.icon
+ self.widget.icon_color = self.icon_color
+ self.widget.md_bg_color = self.md_bg_color
+ self.widget.location_time = self.location_time
+
+ @property
+ def app(self):
+ k = "app"
+ if not self.options: return None
+ else: return self.options.get(k, None)
+
+ @app.setter
+ def app(self, v):
+ k = "app"
+ if not self.options: self.options = {}
+ self.options[k] = v
+
+ @property
+ def source_dest(self):
+ k = "source_dest"
+ if not self.options: return None
+ else: return self.options.get(k, None)
+
+ @source_dest.setter
+ def source_dest(self, v):
+ k = "source_dest"
+ if not self.options: self.options = {}
+ self.options[k] = v
+
+ @property
+ def icon(self):
+ k = "icon"
+ if not self.options: return None
+ else: return self.options.get(k, None)
+
+ @icon.setter
+ def icon(self, v):
+ k = "icon"
+ if not self.options: self.options = {}
+ self.options[k] = v
+
+ @property
+ def icon_color(self):
+ k = "icon_color"
+ if not self.options: return None
+ else: return self.options.get(k, None)
+
+ @icon_color.setter
+ def icon_color(self, v):
+ k = "icon_color"
+ if not self.options: self.options = {}
+ self.options[k] = v
+
+ @property
+ def md_bg_color(self):
+ k = "md_bg_color"
+ if not self.options: return None
+ else: return self.options.get(k, None)
+
+ @md_bg_color.setter
+ def md_bg_color(self, v):
+ k = "md_bg_color"
+ if not self.options: self.options = {}
+ self.options[k] = v
+
+ @property
+ def location_time(self):
+ k = "location_time"
+ if not self.options: return None
+ else: return self.options.get(k, None)
+
+ @location_time.setter
+ def location_time(self, v):
+ k = "location_time"
+ if not self.options: self.options = {}
+ self.options[k] = v
+
def __repr__(self):
- return "<Marker lon={} lat={} source={}>".format(
- self.lon, self.lat, self.source
- )
+ return f"<{self.cls} Proxy lat={self.lat} lon={self.lon}: {self.options}>"
class SuperCluster:
@@ -285,24 +383,20 @@ class SuperCluster:
"""Load an array of markers.
Once loaded, the index is immutable.
"""
- from time import time
-
self.trees = {}
self.points = points
-
- for index, point in enumerate(points):
- point.id = index
+ for index, point in enumerate(points): point.id = index
clusters = points
for z in range(self.max_zoom, self.min_zoom - 1, -1):
- start = time()
- print("build tree", z)
+ # start = time.time()
+ # print(f"build tree for z={z}, clusters: {len(clusters)}")
self.trees[z + 1] = KDBush(clusters, self.node_size)
- print("kdbush", (time() - start) * 1000)
- start = time()
+ # print("kdbush", (time.time() - start) * 1000)
+ # start = time.time()
clusters = self._cluster(clusters, z)
- print(len(clusters))
- print("clustering", (time() - start) * 1000)
+ # print(f"trees: {len(self.trees)}, clusters: {len(clusters)}")
+ # print("clustering", (time.time() - start) * 1000)
self.trees[self.min_zoom] = KDBush(clusters, self.node_size)
def get_clusters(self, bbox, zoom):
@@ -314,10 +408,9 @@ class SuperCluster:
clusters = []
for i in range(len(ids)):
c = tree.points[ids[i]]
- if isinstance(c, Cluster):
- clusters.append(c)
- else:
- clusters.append(self.points[c.id])
+ if isinstance(c, Cluster): clusters.append(c)
+ else: clusters.append(self.points[c.id])
+
return clusters
def _limit_zoom(self, z):
@@ -333,8 +426,7 @@ class SuperCluster:
for i in range(len(points)):
p = points[i]
# if we've already visited the point at this zoom level, skip it
- if p.zoom <= zoom:
- continue
+ if p.zoom <= zoom: continue
p.zoom = zoom
# find all nearby points
@@ -342,50 +434,104 @@ class SuperCluster:
neighbor_ids = tree.within(p.x, p.y, r)
num_points = 1
- if isinstance(p, Cluster):
- num_points = p.num_points
+ if isinstance(p, Cluster): num_points = p.num_points
wx = p.x * num_points
wy = p.y * num_points
props = None
+ lat_min = float("inf"); lat_max = float("-inf")
+ lon_min = float("inf"); lon_max = float("-inf")
+
+ # Include seed point's extents/position in the calculation
+ if isinstance(p, Cluster):
+ lat_min = min(lat_min, p.extents[0])
+ lat_max = max(lat_max, p.extents[1])
+ lon_min = min(lon_min, p.extents[2])
+ lon_max = max(lon_max, p.extents[3])
+ else:
+ lat_min = min(lat_min, p.lat)
+ lat_max = max(lat_max, p.lat)
+ lon_min = min(lon_min, p.lon)
+ lon_max = max(lon_max, p.lon)
+
for j in range(len(neighbor_ids)):
b = tree.points[neighbor_ids[j]]
- # filter out neighbors that are too far or already processed
+ # Filter out neighbors that are too far or already processed
if zoom < b.zoom:
num_points2 = 1
- if isinstance(b, Cluster):
- num_points2 = b.num_points
- # save the zoom (so it doesn't get processed twice)
+ if isinstance(b, Cluster): num_points2 = b.num_points
+ # Save the zoom (so it doesn't get processed twice)
b.zoom = zoom
- # accumulate coordinates for calculating weighted center
+ # Accumulate coordinates for calculating weighted center
wx += b.x * num_points2
wy += b.y * num_points2
num_points += num_points2
b.parent_id = i
+ # Use extents for clusters, lat/lon for markers
+ if isinstance(b, Cluster):
+ lat_min = min(lat_min, b.extents[0])
+ lat_max = max(lat_max, b.extents[1])
+ lon_min = min(lon_min, b.extents[2])
+ lon_max = max(lon_max, b.extents[3])
+ else:
+ lat_min = min(lat_min, b.lat)
+ lat_max = max(lat_max, b.lat)
+ lon_min = min(lon_min, b.lon)
+ lon_max = max(lon_max, b.lon)
+
if num_points == 1:
+ c = points[i]
+ p.extents = [lat_min, lat_max, lon_min, lon_max]
c_append(p)
+
else:
p.parent_id = i
- c_append(
- Cluster(wx / num_points, wy / num_points, num_points, i, props)
- )
+ extents = [lat_min, lat_max, lon_min, lon_max]
+ c_append(Cluster(wx / num_points, wy / num_points, num_points, i, props, extents))
+
return clusters
class ClusterMapMarker(MapMarker):
- source = StringProperty(join(dirname(__file__), "icons", "cluster.png"))
+ source = StringProperty(join(dirname(__file__), "icons", "cluster_medium.png"))
cluster = ObjectProperty()
num_points = NumericProperty()
- text_color = ListProperty([0.1, 0.1, 0.1, 1])
-
- def on_cluster(self, instance, cluster):
- self.num_points = cluster.num_points
+ text_color = ListProperty([0.9, 0.9, 0.9, 1])
+ extents = ListProperty(None)
+ delegate = ObjectProperty(None)
+
+ def on_cluster(self, instance, cluster): self.num_points = cluster.num_points
+ # def on_touch_down(self, touch): return False
+ def on_release(self):
+ RNS.log(f"{self} -> {self.delegate}: {self.num_points} / {self.extents}")
+ e = self.extents
+ if e[0] < 90 and e[1] > -90 and e[2] < 180 and e[3] > -180:
+ if self.delegate and e: self.delegate.show_bbox(e[0], e[1], e[2], e[3])
+
+class MDTelemetryIconButton(MDIconButton):
+ def on_release(self, *args): self.app.map_display_telemetry(self)
+
+class TelemetryClusterMarker(CustomMapMarker):
+ source_dest = ObjectProperty(None)
+ app = ObjectProperty(None)
+ icon = StringProperty("marker")
+ icon_color = ListProperty([1, 1, 1, 1])
+ md_bg_color = ListProperty([1, 1, 1, 1])
+ location_time = NumericProperty(0)
- def on_touch_down(self, touch):
- return False
+ def __init__(self, **kwargs):
+ if "md_bg_color" in kwargs:
+ bg = kwargs["md_bg_color"]
+ if len(bg) >= 3:
+ lim = 0.5
+ lum = (bg[0]+bg[1]+bg[2])/3
+ if lum >= lim: self.source = join(dirname(__file__), "icons", "marker_dark.png")
+ else: self.source = join(dirname(__file__), "icons", "marker_dark.png")
+ super(TelemetryClusterMarker, self).__init__(**kwargs)
+ self.texture_update()
class ClusteredMarkerLayer(MapLayer):
cluster_cls = ObjectProperty(ClusterMapMarker)
@@ -394,15 +540,16 @@ class ClusteredMarkerLayer(MapLayer):
cluster_radius = NumericProperty("40dp")
cluster_extent = NumericProperty(512)
cluster_node_size = NumericProperty(64)
+ delegate = ObjectProperty(None)
def __init__(self, **kwargs):
+ self.ready = False
self.cluster = None
self.cluster_markers = []
super().__init__(**kwargs)
def add_marker(self, lon, lat, cls=MapMarker, options=None):
- if options is None:
- options = {}
+ if options is None: options = {}
marker = Marker(lon, lat, cls, options)
self.cluster_markers.append(marker)
return marker
@@ -410,40 +557,71 @@ class ClusteredMarkerLayer(MapLayer):
def remove_marker(self, marker):
self.cluster_markers.remove(marker)
- def reposition(self):
- if self.cluster is None:
- self.build_cluster()
+ def rebuild(self):
+ RNS.log(f"Rebuilding map clusters", RNS.LOG_DEBUG) if RNS.sl(RNS.LOG_DEBUG) else None
+ for m in self.cluster_markers: m.zoom = float("inf")
+ self.build_cluster()
+
+ def reposition(self, rebuild=False):
+ st = time.time()
+ if not self.ready: return
+ if self.cluster is None: self.build_cluster()
+ elif rebuild: self.rebuild()
margin = dp(48)
mapview = self.parent
set_marker_position = self.set_marker_position
bbox = mapview.get_bbox(margin)
bbox = (bbox[1], bbox[0], bbox[3], bbox[2])
self.clear_widgets()
- for point in self.cluster.get_clusters(bbox, mapview.zoom):
+
+ visible_points = self.cluster.get_clusters(bbox, mapview.zoom)
+ visible_widgets = []
+ for point in visible_points:
widget = point.widget
- if widget is None:
- widget = self.create_widget_for(point)
- set_marker_position(mapview, widget)
+ if widget is None: widget = self.create_widget_for(point)
+ visible_widgets.append((point, widget))
+
+ if mapview._zoom >= 19:
+ scatter_markers = [p for p, w in visible_widgets if isinstance(p, Marker)]
+ try: scatter_offsets = mapview.scatter_markers(scatter_markers)
+ except Exception as e:
+ RNS.log(f"Error while getting scatter offsets: {e}", RNS.LOG_WARNING)
+ scatter_offsets = {}
+
+ for point, widget in visible_widgets:
+ if isinstance(point, Marker): widget._scatter_offset = scatter_offsets.get(point, (0.0, 0.0))
+ else: widget._scatter_offset = (0.0, 0.0)
+ else:
+ for point, widget in visible_widgets: widget._scatter_offset = (0.0, 0.0)
+
+ for point, widget in visible_widgets: set_marker_position(mapview, widget)
+ for point, widget in sorted(visible_widgets, key=lambda pw: pw[1].y, reverse=True):
self.add_widget(widget)
+
+ # RNS.log(f"Clustering done in {RNS.prettyshorttime(time.time()-st)}", RNS.LOG_DEBUG) if RNS.sl(RNS.LOG_DEBUG) else None
def build_cluster(self):
- self.cluster = SuperCluster(
- min_zoom=self.cluster_min_zoom,
- max_zoom=self.cluster_max_zoom,
- radius=self.cluster_radius,
- extent=self.cluster_extent,
- node_size=self.cluster_node_size,
- )
+ self.cluster = SuperCluster(min_zoom=self.cluster_min_zoom, max_zoom=self.cluster_max_zoom,
+ radius=self.cluster_radius, extent=self.cluster_extent,
+ node_size=self.cluster_node_size)
self.cluster.load(self.cluster_markers)
def create_widget_for(self, point):
if isinstance(point, Marker):
point.widget = point.cls(lon=point.lon, lat=point.lat, **point.options)
+ point.widget.extents = getattr(point, 'extents', None)
+
elif isinstance(point, Cluster):
- point.widget = self.cluster_cls(lon=point.lon, lat=point.lat, cluster=point)
+ extents = point.extents
+ point.widget = self.cluster_cls(lon=point.lon, lat=point.lat, cluster=point, extents=extents)
+ point.widget.delegate = self.delegate
+
return point.widget
def set_marker_position(self, mapview, marker):
x, y = mapview.get_window_xy_from(marker.lat, marker.lon, mapview.zoom)
+ offset = getattr(marker, '_scatter_offset', (0.0, 0.0))
+ x += offset[0]
+ y += offset[1]
marker.x = int(x - marker.width * marker.anchor_x)
marker.y = int(y - marker.height * marker.anchor_y)
diff --git a/sbapp/mapview/icons/cluster_dark.png b/sbapp/mapview/icons/cluster_dark.png
new file mode 100644
index 00000000..eac621da
Binary files /dev/null and b/sbapp/mapview/icons/cluster_dark.png differ
diff --git a/sbapp/mapview/icons/cluster_medium.png b/sbapp/mapview/icons/cluster_medium.png
new file mode 100644
index 00000000..f8142f89
Binary files /dev/null and b/sbapp/mapview/icons/cluster_medium.png differ
diff --git a/sbapp/mapview/view.py b/sbapp/mapview/view.py
index 38bc67b8..2abaf9ae 100644
--- a/sbapp/mapview/view.py
+++ b/sbapp/mapview/view.py
@@ -4,7 +4,7 @@ __all__ = ["MapView", "MapMarker", "MapMarkerPopup", "MapLayer", "MarkerMapLayer
import webbrowser
from itertools import takewhile
-from math import ceil
+from math import ceil, cos, log2, pi, sin, sqrt
from os.path import dirname, join
from kivy.clock import Clock
@@ -169,10 +169,8 @@ class CustomMapMarker(ButtonBehavior, Image):
if len(bg) >= 3:
lim = 0.5
lum = (bg[0]+bg[1]+bg[2])/3
- if lum >= lim:
- self.source = join(dirname(__file__), "icons", "marker_dark.png")
- else:
- self.source = join(dirname(__file__), "icons", "marker_dark.png")
+ if lum >= lim: self.source = join(dirname(__file__), "icons", "marker_dark.png")
+ else: self.source = join(dirname(__file__), "icons", "marker_dark.png")
super(CustomMapMarker, self).__init__(**kwargs)
self.texture_update()
@@ -314,8 +312,14 @@ class MarkerMapLayer(MapLayer):
markers = sorted(self.markers, key=lambda x: -x.lat)
margin = max((max(marker.size) for marker in markers))
bbox = mapview.get_bbox(margin)
+
+ # Calculate scatter offsets for overlapping markers at high zoom
+ scatter_offsets = mapview.scatter_markers(markers)
+
for marker in markers:
if bbox.collide(marker.lat, marker.lon):
+ # Store scatter offset on marker for apply in set_marker_position
+ marker._scatter_offset = scatter_offsets.get(marker, (0.0, 0.0))
set_marker_position(mapview, marker)
if not marker.parent:
self.insert_marker(marker)
@@ -324,6 +328,10 @@ class MarkerMapLayer(MapLayer):
def set_marker_position(self, mapview, marker):
x, y = mapview.get_window_xy_from(marker.lat, marker.lon, mapview.zoom)
+ # Apply scatter offset if present
+ offset = getattr(marker, '_scatter_offset', (0.0, 0.0))
+ x += offset[0]
+ y += offset[1]
marker.x = int(x - marker.width * marker.anchor_x)
marker.y = int(y - marker.height * marker.anchor_y)
if hasattr(marker, "children"):
@@ -450,6 +458,107 @@ class MapView(Widget):
y = y + self.pos[1]
return x, y
+ def scatter_markers(self, markers, threshold_pixels=12, min_zoom=19, coordinate_tolerance=1e-7):
+ if self._zoom < min_zoom or len(markers) < 2: return {}
+ threshold_pixels = dp(threshold_pixels)
+ ring_scatter_pixels = dp(80)
+
+ # Group markers by coordinate (with tolerance for floating-point)
+ coord_groups = {}
+ for marker in markers:
+ # Round to tolerance for grouping same-location markers
+ key = (round(marker.lat, 7), round(marker.lon, 7))
+ if key not in coord_groups: coord_groups[key] = []
+ coord_groups[key].append(marker)
+
+ offsets = {}
+
+ # Process groups with coordinate matches using concentric rings
+ for key, group in coord_groups.items():
+ if len(group) > 1:
+ # Sort for determinism (use id() for stable ordering)
+ sorted_group = sorted(group, key=lambda m: id(m))
+
+ for idx, marker in enumerate(sorted_group):
+ if idx == 0:
+ # First marker stays at center (no offset)
+ offsets[marker] = (0.0, 0.0)
+ else:
+ # Calculate which ring this marker belongs to
+ # Ring n contains 6*n markers (for n >= 1)
+ # Cumulative: 1 + 3*n*(n+1) markers through ring n
+ # Solve: 3*n² + 3*n + 1 > idx for smallest n
+ n = ceil((-3 + sqrt(9 + 12 * idx)) / 6)
+
+ # Calculate position within ring
+ prev_count = 3 * (n - 1) ** 2 + 3 * (n - 1) + 1 if n > 0 else 0
+ pos_in_ring = idx - prev_count
+
+ # Calculate angle (evenly distributed in ring)
+ angle = 2 * pi * pos_in_ring / (6 * n)
+
+ # Calculate radius (concentric ring spacing)
+ radius = n * ring_scatter_pixels * 0.75
+
+ offsets[marker] = (radius * cos(angle), radius * sin(angle))
+
+ # Process markers close but not identical with repulsive forces
+ # Only process markers not already in same-coordinate groups
+ single_markers = [m for m in markers if len(coord_groups.get((round(m.lat, 7), round(m.lon, 7)), [])) == 1]
+
+ if len(single_markers) >= 2:
+ # Get viewport-relative positions
+ positions = {}
+ for marker in single_markers:
+ x, y = self.get_window_xy_from(marker.lat, marker.lon, self._zoom)
+ positions[marker] = (x - self.center_x, y - self.center_y)
+
+ # Find clusters of nearby markers using centroid-based scattering
+ # Each cluster is a group where markers are within threshold of each other
+ processed = set()
+ threshold_sq = threshold_pixels * threshold_pixels
+
+ for i, marker_i in enumerate(single_markers):
+ if marker_i in processed: continue
+
+ # Find all markers within threshold of this one (cluster detection)
+ cluster = [marker_i]
+ for marker_j in single_markers[i+1:]:
+ if marker_j in processed: continue
+ x_i, y_i = positions[marker_i]
+ x_j, y_j = positions[marker_j]
+ dist_sq = (x_i - x_j) ** 2 + (y_i - y_j) ** 2
+ if dist_sq < threshold_sq:
+ cluster.append(marker_j)
+
+ if len(cluster) > 1:
+ # Calculate centroid of the cluster
+ centroid_x = sum(positions[m][0] for m in cluster) / len(cluster)
+ centroid_y = sum(positions[m][1] for m in cluster) / len(cluster)
+
+ # Offset each marker away from centroid
+ for marker in cluster:
+ x, y = positions[marker]
+ dx = x - centroid_x
+ dy = y - centroid_y
+ dist_sq = dx * dx + dy * dy
+
+ if dist_sq > 0:
+ dist = sqrt(dist_sq)
+ # Calculate offset: push away from centroid
+ # Use a factor that ensures markers spread evenly
+ factor = 0.5 # Base scattering factor
+ offset_len = factor * threshold_pixels
+
+ # Normalize direction and apply offset
+ offset_x = (dx / dist) * offset_len
+ offset_y = (dy / dist) * offset_len
+ offsets[marker] = (offset_x, offset_y)
+
+ processed.add(marker)
+
+ return offsets
+
def center_on(self, *args):
"""Center the map on the coordinate :class:`Coordinate`, or a (lat, lon)
"""
@@ -476,6 +585,80 @@ class MapView(Widget):
self._scatter.pos = 0, 0
self.trigger_update(True)
+ def zoom_to_bbox(self, lat_min, lat_max, lon_min, lon_max, padding=0.85, float_zoom=False):
+ """
+ Zoom and center the map to fit the given bounding box within the view.
+
+ Args:
+ lat_min, lat_max: Latitude bounds (lat_min < lat_max)
+ lon_min, lon_max: Longitude bounds (lon_min < lon_max)
+ padding: Fraction of view to use (0.85 = 85% of view, leaving 15% margin)
+ float_zoom: If True (default), use calculated floating-point zoom level.
+ If False, use iterative search for integer zoom level only.
+ """
+ if not self.width or not self.height:
+ return # Widget not sized yet
+
+ # Calculate center
+ lat_center = (lat_min + lat_max) / 2.0
+ lon_center = (lon_min + lon_max) / 2.0
+
+ map_source = self.map_source
+ min_zoom = map_source.get_min_zoom()
+ max_zoom = map_source.get_max_zoom()
+
+ # Available view size with padding
+ view_width = self.width * padding
+ view_height = self.height * padding
+
+ if float_zoom:
+ # Calculate optimal floating-point zoom level via closed-form
+ # Get pixel extents at zoom 0
+ x_min = map_source.get_x(0, lon_min)
+ x_max = map_source.get_x(0, lon_max)
+ y_min = map_source.get_y(0, lat_max) # Note: y increases downward
+ y_max = map_source.get_y(0, lat_min)
+
+ pixel_width_0 = abs(x_max - x_min)
+ pixel_height_0 = abs(y_max - y_min)
+
+ # Calculate zoom where each dimension fits
+ if pixel_width_0 > 0:
+ zoom_width = log2(view_width / pixel_width_0)
+ else:
+ zoom_width = max_zoom
+
+ if pixel_height_0 > 0:
+ zoom_height = log2(view_height / pixel_height_0)
+ else:
+ zoom_height = max_zoom
+
+ # Use the limiting dimension (smaller zoom = more zoomed out)
+ calculated_zoom = min(zoom_width, zoom_height)
+ calculated_zoom = clamp(calculated_zoom, min_zoom, max_zoom)
+ target_zoom = calculated_zoom
+ else:
+ # Iterative search for integer zoom level
+ best_zoom = min_zoom
+ for zoom in range(int(min_zoom), int(max_zoom) + 1):
+ # Get pixel positions for corners at this zoom
+ x_min = map_source.get_x(zoom, lon_min)
+ x_max = map_source.get_x(zoom, lon_max)
+ y_min = map_source.get_y(zoom, lat_max)
+ y_max = map_source.get_y(zoom, lat_min)
+
+ pixel_width = abs(x_max - x_min)
+ pixel_height = abs(y_max - y_min)
+
+ if pixel_width <= view_width and pixel_height <= view_height: best_zoom = zoom
+ else:
+ # Going too far, stop here
+ if best_zoom > min_zoom: break
+ target_zoom = int(best_zoom)
+
+ self.set_zoom_at(target_zoom, self.center_x, self.center_y)
+ self.center_on(lat_center, lon_center)
+
def set_zoom_at(self, zoom, x, y, scale=None):
"""Sets the zoom level, leaving the (x, y) at the exact same point
in the view.
@@ -884,15 +1067,10 @@ class MapView(Widget):
def do_update(self, dt):
zoom = self._zoom
scale = self._scale
- self.lon = self.map_source.get_lon(
- zoom, (self.center_x - self._scatter.x) / scale - self.delta_x
- )
- self.lat = self.map_source.get_lat(
- zoom, (self.center_y - self._scatter.y) / scale - self.delta_y
- )
+ self.lon = self.map_source.get_lon(zoom, (self.center_x - self._scatter.x) / scale - self.delta_x)
+ self.lat = self.map_source.get_lat(zoom, (self.center_y - self._scatter.y) / scale - self.delta_y)
self.dispatch("on_map_relocated", zoom, Coordinate(self.lon, self.lat))
- for layer in self._layers:
- layer.reposition()
+ for layer in self._layers: layer.reposition()
if self._need_redraw_full:
self._need_redraw_full = False
diff --git a/sbapp/sideband/core.py b/sbapp/sideband/core.py
index cf18cc8b..7afa40e5 100644
--- a/sbapp/sideband/core.py
+++ b/sbapp/sideband/core.py
@@ -807,6 +807,9 @@ class SidebandCore():
if not "map_use_offline" in self.config: self.config["map_use_offline"] = False
if not "map_use_online" in self.config: self.config["map_use_online"] = True
if not "map_layer" in self.config: self.config["map_layer"] = None
+ if not "map_cluster" in self.config: self.config["map_cluster"] = True
+ if not "map_interfaces" in self.config: self.config["map_interfaces"] = True
+ if not "discover_interfaces" in self.config: self.config["discover_interfaces"] = True
if not "map_storage_path" in self.config: self.config["map_storage_path"] = None
if not "map_storage_file" in self.config: self.config["map_storage_file"] = None
@@ -2360,8 +2363,7 @@ class SidebandCore():
result = dbc.fetchall()
- if len(result) < 1:
- return None
+ if len(result) < 1: return None
else:
results = {}
for entry in result:
@@ -4461,6 +4463,10 @@ class SidebandCore():
RNS.log("Error while adding Modem Interface. The contained exception was: "+str(e))
self.interface_modem = None
+ if self.config["discover_interfaces"]:
+ RNS.log(f"Enabling interface discovery", RNS.LOG_NOTICE)
+ RNS.Transport.discover_interfaces()
+
RNS.log("Reticulum started, activating LXMF...")
self.setstate("init.loadingstate", "Activating LXMF Router")
diff --git a/sbapp/sideband/geo.py b/sbapp/sideband/geo.py
index 77ad1571..e950a8c1 100644
--- a/sbapp/sideband/geo.py
+++ b/sbapp/sideband/geo.py
@@ -301,10 +301,12 @@ def geoid_offset(lat, lon):
return geoid_height.get(lat, lon)
def altitude_to_aamsl(alt, lat, lon):
- if alt == None or lat == None or lon == None:
- return None
- else:
- return alt-geoid_offset(lat, lon)
+ if alt == None or lat == None or lon == None: return None
+ else: return alt-geoid_offset(lat, lon)
+
+def aamsl_to_altitude(aamsl, lat, lon):
+ if aamsl == None or lat == None or lon == None: return None
+ else: return aamsl+geoid_offset(lat, lon)
######################################################
# GeoidHeight class by Kim Vandry <vandry@TZoNE.ORG> #
diff --git a/sbapp/ui/layouts.py b/sbapp/ui/layouts.py
index efdfeb58..b56db15a 100644
--- a/sbapp/ui/layouts.py
+++ b/sbapp/ui/layouts.py
@@ -750,6 +750,24 @@ MDScreen:
size_hint_y: None
height: dp(24)
+ MDLabel:
+ id: connectivity_discover_interfaces_label
+ text: "Discover Interfaces"
+ font_style: "H6"
+ # disabled: True
+
+ MDSwitch:
+ id: connectivity_discover_interfaces
+ active: False
+ pos_hint: {"center_y": 0.3}
+ # disabled: True
+
+ MDBoxLayout:
+ orientation: "horizontal"
+ padding: [0,dp(12),dp(24),0]
+ size_hint_y: None
+ height: dp(36)
+
MDLabel:
id: connectivity_obfuscate_hops_label
text: "Obfuscate Hop Counts"
@@ -921,156 +939,6 @@ MDScreen:
height: dp(256)
"""
-layout_map_settings_screen = """
-MDScreen:
- name: "map_settings_screen"
-
- BoxLayout:
- orientation: "vertical"
-
- MDTopAppBar:
- title: "Map Configuration"
- anchor_title: "left"
- elevation: 0
- left_action_items:
- [['menu', lambda x: root.app.nav_drawer.set_state("open")]]
- right_action_items:
- [
- ['close', lambda x: root.app.close_sub_map_action(self)],
- ]
-
- ScrollView:
- id: map_settings_scrollview
-
- MDBoxLayout:
- orientation: "vertical"
- size_hint_y: None
- height: self.minimum_height
- padding: [dp(28), dp(48), dp(28), dp(16)]
-
- MDLabel:
- text: "Configure Map"
- font_style: "H6"
-
- MDLabel:
- id: map_config_info
- markup: True
- text: ""
- size_hint_y: None
- text_size: self.width, None
- height: self.texture_size[1]
-
- MDLabel:
- markup: True
- text: "\\n"
- size_hint_y: None
- text_size: self.width, None
- height: self.texture_size[1]
-
- MDBoxLayout:
- orientation: "horizontal"
- padding: [0,0,dp(24),0]
- size_hint_y: None
- height: dp(48)
-
- MDLabel:
- text: "Use online map sources"
- font_style: "H6"
-
- MDSwitch:
- id: map_use_online
- pos_hint: {"center_y": 0.3}
- active: False
-
- MDBoxLayout:
- orientation: "horizontal"
- padding: [0,0,dp(24),0]
- size_hint_y: None
- height: dp(48)
-
- MDLabel:
- text: "Use offline map source"
- font_style: "H6"
-
- MDSwitch:
- id: map_use_offline
- pos_hint: {"center_y": 0.3}
- active: False
-
- MDBoxLayout:
- orientation: "horizontal"
- padding: [0,0,dp(24),0]
- size_hint_y: None
- height: dp(48)
-
- MDLabel:
- id: map_storage_external_label
- text: "Use external storage path"
- font_style: "H6"
-
- MDSwitch:
- id: map_storage_external
- pos_hint: {"center_y": 0.3}
- active: False
-
- MDBoxLayout:
- orientation: "vertical"
- size_hint_y: None
- spacing: dp(24)
- height: self.minimum_height
- padding: [0, dp(24), 0, 0]
-
- MDRectangleFlatIconButton:
- id: map_select_button
- icon: "list-box-outline"
- text: "Select MBTiles Map"
- padding: [dp(0), dp(14), dp(0), dp(14)]
- icon_size: dp(24)
- font_size: dp(16)
- size_hint: [1.0, None]
- on_release: root.app.map_select_file_action(self)
- disabled: False
-
- MDRectangleFlatIconButton:
- id: map_cache_button
- icon: "map-clock-outline"
- text: "Clear map cache"
- padding: [dp(0), dp(14), dp(0), dp(14)]
- icon_size: dp(24)
- font_size: dp(16)
- size_hint: [1.0, None]
- on_release: root.app.map_clear_cache(self)
- disabled: False
-"""
-
-layout_map_screen = """
-MDScreen:
- name: "map_screen"
-
- BoxLayout:
- orientation: "vertical"
-
- MDTopAppBar:
- title: "Map"
- anchor_title: "left"
- elevation: 0
- left_action_items:
- [['menu', lambda x: root.app.nav_drawer.set_state("open")]]
- right_action_items:
- [
- #['format-list-bulleted-type', lambda x: root.app.map_object_list(self)], # Object List
- ['arrow-down-bold-hexagon-outline', lambda x: root.app.telemetry_request_action(self)], # Download telemetry
- ['upload-lock', lambda x: root.app.telemetry_send_update(self)], # Send telemetry update
- ['layers', lambda x: root.app.map_layers_action(self)],
- ['wrench-cog', lambda x: root.app.map_settings_action(self)],
- ['crosshairs-gps', lambda x: root.app.map_own_location_action(self)],
- ['close', lambda x: root.app.close_any_action(self)],
- ]
-
- MDBoxLayout:
- id: map_layout
-"""
-
layout_plugins_screen = """
MDScreen:
name: "plugins_screen"
diff --git a/sbapp/ui/map.py b/sbapp/ui/map.py
new file mode 100644
index 00000000..5ba25cbf
--- /dev/null
+++ b/sbapp/ui/map.py
@@ -0,0 +1,942 @@
+import os
+import RNS
+import time
+import pathlib
+
+from typing import Union
+from kivy.metrics import dp,sp
+from kivy.lang.builder import Builder
+from kivy.core.clipboard import Clipboard
+from kivy.utils import escape_markup
+from kivymd.uix.recycleview import MDRecycleView
+from kivymd.uix.list import OneLineIconListItem
+from kivymd.uix.button import MDRectangleFlatButton
+from kivymd.uix.dialog import MDDialog
+from kivymd.uix.filemanager import MDFileManager
+from kivymd.uix.button import MDIconButton
+from kivymd.icon_definitions import md_icons
+from kivymd.toast import toast
+from kivy.properties import StringProperty, BooleanProperty, ObjectProperty, ListProperty
+from kivy.effects.scroll import ScrollEffect
+from kivy.clock import Clock
+from sideband.sense import Telemeter
+from mapview import CustomMapMarker
+from mapview.view import MapMarker
+from mapview.mbtsource import MBTilesMapSource
+from mapview.source import MapSource
+from mapview.clustered_marker_layer import ClusteredMarkerLayer, ClusterMapMarker, TelemetryClusterMarker
+import threading
+from datetime import datetime
+
+if RNS.vendor.platformutils.get_platform() == "android":
+ from ui.helpers import ts_format
+ from android.permissions import request_permissions, check_permission
+else:
+ from .helpers import ts_format
+
+class Map():
+ IF_UPDATE_INTERVAL = 60
+ def __init__(self, app):
+ self.app = app
+ self.screen = None
+ self.map_markers = {}
+ self.map_layer = None
+ self.map_cache = self.app.sideband.map_cache
+ self.offline_source = None
+ self.clustered_layer = None
+ self.clustered_markers = {}
+ self.interface_discovery_hashes = {}
+ self.last_interfaces_update = 0
+
+ if not self.app.root.ids.screen_manager.has_screen("map_screen"):
+ msource = self.get_source()
+ mzoom = self.app.sideband.config["map_zoom"]
+ mlat = self.app.sideband.config["map_lat"]; mlon = self.app.sideband.config["map_lon"]
+ if mzoom > msource.max_zoom: mzoom = msource.max_zoom
+ if mzoom < msource.min_zoom: mzoom = msource.min_zoom
+ if mlat < -89: mlat = -89
+ if mlat > 89: mlat = 89
+ if mlon < -179: mlon = -179
+ if mlon > 179: mlon = 179
+
+ self.screen = Builder.load_string(layout_map_screen)
+ self.screen.app = self.app
+ self.screen.delegate = self
+ self.app.root.ids.screen_manager.add_widget(self.screen)
+
+ from mapview import MapView
+ mapview = MapView(map_source=msource, zoom=mzoom, lat=mlat, lon=mlon)
+ mapview.snap_to_zoom = False
+ mapview.double_tap_zoom = True
+ self.map = mapview
+ self.screen.ids.map_layout.map = mapview
+ self.screen.ids.map_layout.add_widget(self.screen.ids.map_layout.map)
+
+ self.clustered_layer = ClusteredMarkerLayer(cluster_radius=100, cluster_cls=ClusterMapMarker,
+ cluster_min_zoom=0, cluster_max_zoom=18, delegate=self)
+ self.map.add_layer(self.clustered_layer)
+
+ def show(self, location, zoom=16):
+ mz = zoom
+ lat = location["latitude"]
+ lon = location["longitude"]
+ if mz > self.map.map_source.max_zoom: mz = self.map.map_source.max_zoom
+ if mz < self.map.map_source.min_zoom: mz = self.map.map_source.min_zoom
+ self.map.zoom = mz
+ self.map.trigger_update(True)
+ self.map.center_on(lat,lon)
+ self.map.trigger_update(True)
+
+ def show_bbox(self, lat_min, lat_max, lon_min, lon_max, padding=0.85, float_zoom=False):
+ self.map.zoom_to_bbox(lat_min, lat_max, lon_min, lon_max, padding=padding, float_zoom=float_zoom)
+
+ def fm_got_path(self, path):
+ self.fm_exited()
+ try:
+ source = MBTilesMapSource(path)
+ self.update_source()
+ self.app.sideband.config["map_storage_file"] = path
+ self.app.sideband.config["map_storage_path"] = str(pathlib.Path(path).parent.resolve())
+ self.app.sideband.save_configuration()
+
+ if RNS.vendor.platformutils.is_android(): toast("Using \""+os.path.basename(path)+"\" as offline map")
+ else:
+ ok_button = MDRectangleFlatButton(text="OK",font_size=dp(18))
+ ate_dialog = MDDialog(title="Map Set", text="Using \""+os.path.basename(path)+"\" as offline map",
+ buttons=[ ok_button ])
+ ok_button.bind(on_release=ate_dialog.dismiss)
+ ate_dialog.open()
+
+ except Exception as e:
+ RNS.log(f"Error while loading map \"{path}\": "+str(e), RNS.LOG_ERROR)
+ if RNS.vendor.platformutils.get_platform() == "android":
+ toast("Could not load map \""+os.path.basename(path)+"\"")
+ else:
+ ok_button = MDRectangleFlatButton(text="OK",font_size=dp(18))
+ map_dialog = MDDialog(title="Map Error", buttons=[ ok_button ],
+ text="The specified map file could not be loaded. Make sure the selected file is an MBTiles map in raster format. Vector maps are currently not supported.")
+ ok_button.bind(on_release=map_dialog.dismiss)
+ map_dialog.open()
+ self.app.sideband.config["map_storage_file"] = None
+ self.app.sideband.config["map_use_offline"] = False
+ self.app.sideband.config["map_use_online"] = True
+ self.settings_load_states()
+ self.update_source()
+
+ def fm_exited(self, *args):
+ self.manager_open = False
+ self.file_manager.close()
+ self.update_source()
+
+ def select_file_action(self, sender=None):
+ perm_ok = False
+ if self.app.sideband.config["map_storage_path"] == None:
+ if RNS.vendor.platformutils.is_android():
+ perm_ok = self.check_storage_permission()
+
+ if self.app.sideband.config["map_storage_external"]:
+ path = secondary_external_storage_path()
+ if path == None: path = primary_external_storage_path()
+ else:
+ path = primary_external_storage_path()
+
+ else:
+ perm_ok = True
+ if self.app.sideband.config["map_storage_external"]: path = "/"
+ else: path = os.path.expanduser("~")
+ else:
+ perm_ok = True
+ path = self.app.sideband.config["map_storage_path"]
+
+ if perm_ok and path != None:
+ try:
+ self.file_manager = MDFileManager(exit_manager=self.fm_exited, select_path=self.fm_got_path)
+ self.file_manager.ext = [".mbtiles"]
+ self.file_manager.show(path)
+
+ except Exception as e:
+ self.app.sideband.config["map_storage_path"] = None
+ self.app.sideband.save_configuration()
+ if RNS.vendor.platformutils.is_android():
+ toast("Error reading directory, check permissions!")
+ else:
+ ok_button = MDRectangleFlatButton(text="OK",font_size=dp(18))
+ ate_dialog = MDDialog(title="Error", text="Could not read directory, check permissions!", buttons=[ ok_button ])
+ ok_button.bind(on_release=ate_dialog.dismiss)
+ ate_dialog.open()
+
+ else:
+ self.app.sideband.config["map_storage_path"] = None
+ self.app.sideband.save_configuration()
+ if RNS.vendor.platformutils.is_android(): toast("No file access, check permissions!")
+ else:
+ ok_button = MDRectangleFlatButton(text="OK",font_size=dp(18))
+ ate_dialog = MDDialog(
+ title="Error",
+ text="No file access, check permissions!",
+ buttons=[ ok_button ],
+ )
+ ok_button.bind(on_release=ate_dialog.dismiss)
+ ate_dialog.open()
+
+ def get_offline_source(self):
+ if self.offline_source != None:
+ return self.offline_source
+ else:
+ try:
+ current_map_path = self.app.sideband.config["map_storage_file"]
+ if current_map_path == None:
+ raise ValueError("Map path cannot be None")
+ source = MBTilesMapSource(current_map_path, cache_dir=self.map_cache)
+ self.offline_source = source
+ return self.offline_source
+
+ except Exception as e:
+ RNS.log(f"Error while loading map from \"{current_map_path}\": "+str(e))
+ self.app.sideband.config["map_storage_file"] = None
+ self.app.sideband.config["map_use_offline"] = False
+ self.app.sideband.config["map_use_online"] = True
+ self.app.sideband.save_configuration()
+ self.settings_load_states()
+
+ return None
+
+ def get_source(self):
+ source = None
+ if self.app.sideband.config["map_use_offline"]: source = self.get_offline_source()
+ if source == None: source = MapSource.from_provider("osm", cache_dir=self.map_cache, quad_key=False)
+ return source
+
+ def update_source(self, source=None):
+ ns = source or self.get_source()
+ if self.map != None:
+
+ if source != None:
+ maxz = source.max_zoom
+ minz = source.min_zoom
+ if self.map.zoom > maxz:
+ mz = maxz; px, py = self.get_zoom_center(); self.map.set_zoom_at(mz, px, py)
+
+ if self.map.zoom < minz:
+ mz = minz; px, py = self.get_zoom_center(); self.map.set_zoom_at(mz, px, py)
+
+ m = self.map
+ nlat = self.map.lat
+ nlon = self.map.lon
+ if nlat < -89: nlat = -89
+ if nlat > 89: nlat = 89
+ if nlon < -179: nlon = -179
+ if nlon > 179: nlon = 179
+ self.map.center_on(nlat,nlon)
+
+
+ self.map.map_source = ns
+
+ def interfaces_action(self, sender=None):
+ self.app.sideband.config["map_interfaces"] ^= True
+ if self.app.sideband.config["map_interfaces"]: self.app.sideband.config["map_cluster"] = True
+ self.update_markers()
+
+ def layers_action(self, sender=None):
+ try:
+ ml = self.map_layer
+ layers = []
+ if self.app.sideband.config["map_use_offline"]: layers.append("offline")
+ if self.app.sideband.config["map_use_online"]:
+ layers.append("osm")
+ layers.append("ve")
+
+ if ml == None: ml = layers[0]
+ if not ml in layers: ml = layers[0]
+
+ mli = layers.index(ml)
+ mli = (mli+1)%len(layers)
+ ml = layers[mli]
+
+ source = None
+ if ml == "offline": source = self.get_offline_source()
+ if ml == "osm": source = MapSource.from_provider("osm", cache_dir=self.map_cache, quad_key=False)
+ if ml == "ve": source = MapSource.from_provider("ve", cache_dir=self.map_cache, quad_key=True)
+
+ if source != None:
+ self.map_layer = ml
+ self.update_source(source)
+
+ except Exception as e: RNS.log("Error while switching map layer: "+str(e), RNS.LOG_ERROR)
+
+ map_nav_divisor = 12
+ map_nav_zoom = 0.25
+ def nav_left(self, sender=None, modifier=1.0):
+ if self.map != None:
+ bb = self.map.get_bbox()
+ lat_span = abs(bb[0] - bb[2])
+ lon_span = abs(bb[1] - bb[3])
+ span = min(lat_span, lon_span)
+ delta = (-span/self.map_nav_divisor)*modifier
+ self.map.center_on(self.map.lat, self.map.lon+delta)
+
+ def nav_right(self, sender=None, modifier=1.0):
+ if self.map != None:
+ bb = self.map.get_bbox()
+ lat_span = abs(bb[0] - bb[2])
+ lon_span = abs(bb[1] - bb[3])
+ span = min(lat_span, lon_span)
+ delta = (span/self.map_nav_divisor)*modifier
+ self.map.center_on(self.map.lat, self.map.lon+delta)
+
+ def nav_up(self, sender=None, modifier=1.0):
+ if self.map != None:
+ bb = self.map.get_bbox()
+ lat_span = abs(bb[0] - bb[2])
+ lon_span = abs(bb[1] - bb[3])
+ span = min(lat_span, lon_span)
+ delta = (span/self.map_nav_divisor)*modifier
+ self.map.center_on(self.map.lat+delta, self.map.lon)
+
+ def nav_down(self, sender=None, modifier=1.0):
+ if self.map != None:
+ bb = self.map.get_bbox()
+ lat_span = abs(bb[0] - bb[2])
+ lon_span = abs(bb[1] - bb[3])
+ span = min(lat_span, lon_span)
+ delta = (-span/self.map_nav_divisor)*modifier
+ self.map.center_on(self.map.lat+delta, self.map.lon)
+
+ def get_zoom_center(self):
+ bb = self.map.get_bbox()
+ slat = (bb[2]-bb[0])/2; slon = (bb[3]-bb[1])/2
+ zlat = bb[0]+slat; zlon = bb[1]+slon
+ return self.map.get_window_xy_from(zlat, zlon, self.map.zoom)
+
+ def nav_zoom_out(self, sender=None, modifier=1.0):
+ if self.map != None:
+ zd = -self.map_nav_zoom*modifier
+ if self.map.zoom+zd > self.map.map_source.min_zoom:
+ px, py = self.get_zoom_center()
+ self.map.animated_diff_scale_at(zd, px, py)
+
+ def nav_zoom_in(self, sender=None, modifier=1.0):
+ if self.map != None:
+ zd = self.map_nav_zoom*modifier
+ if self.map.zoom+zd < self.map.map_source.max_zoom or self.map.scale < 3.0:
+ px, py = self.get_zoom_center()
+ self.map.animated_diff_scale_at(zd, px, py)
+
+ def settings_load_states(self):
+ if self.map_settings_screen != None:
+ self.map_settings_screen.ids.map_use_online.active = self.app.sideband.config["map_use_online"]
+ self.map_settings_screen.ids.map_use_offline.active = self.app.sideband.config["map_use_offline"]
+ self.map_settings_screen.ids.map_storage_external.active = self.app.sideband.config["map_storage_external"]
+ self.map_settings_screen.ids.map_cluster.active = self.app.sideband.config["map_cluster"]
+ self.map_settings_screen.ids.map_interfaces.active = self.app.sideband.config["map_interfaces"]
+
+ def settings_init(self):
+ self.settings_load_states()
+ def map_settings_save(sender=None, event=None):
+ self.app.sideband.config["map_storage_external"] = self.map_settings_screen.ids.map_storage_external.active
+ self.app.sideband.config["map_use_online"] = self.map_settings_screen.ids.map_use_online.active
+ self.app.sideband.config["map_use_offline"] = self.map_settings_screen.ids.map_use_offline.active
+ self.app.sideband.config["map_cluster"] = self.map_settings_screen.ids.map_cluster.active
+ self.app.sideband.config["map_interfaces"] = self.map_settings_screen.ids.map_interfaces.active
+ self.app.sideband.save_configuration()
+
+ def external_toggle(sender=None, event=None):
+ self.app.sideband.config["map_storage_path"] = None
+ map_settings_save()
+
+ def offline_toggle(sender=None, event=None):
+ if self.map_settings_screen.ids.map_use_offline.active:
+ if self.app.sideband.config["map_storage_file"] == None: self.map_select_file_action()
+ else: self.map_settings_screen.ids.map_use_online.active = True
+ map_settings_save(); self.update_source()
+
+ def online_toggle(sender=None, event=None):
+ if self.map_settings_screen.ids.map_use_online.active: pass
+ else: self.map_settings_screen.ids.map_use_offline.active = True
+ map_settings_save(); self.update_source()
+
+ self.map_settings_screen.ids.map_use_offline.bind(active=offline_toggle)
+ self.map_settings_screen.ids.map_use_online.bind(active=online_toggle)
+ self.map_settings_screen.ids.map_storage_external.bind(active=external_toggle)
+ self.map_settings_screen.ids.map_cluster.bind(active=map_settings_save)
+ self.map_settings_screen.ids.map_interfaces.bind(active=map_settings_save)
+
+ def settings_action(self, sender=None, direction="left"):
+ if not self.app.root.ids.screen_manager.has_screen("map_settings_screen"):
+ self.map_settings_screen = Builder.load_string(layout_map_settings_screen)
+ self.map_settings_screen.app = self.app
+ self.map_settings_screen.delegate = self
+ self.app.root.ids.screen_manager.add_widget(self.map_settings_screen)
+ self.map_settings_screen.ids.map_config_info.text = "\n\nSideband can use map sources from the Internet, or a map source stored locally on this device in MBTiles format."
+ self.map_settings_screen.ids.map_settings_scrollview.effect_cls = ScrollEffect
+ self.settings_init()
+
+ else: self.settings_load_states()
+
+ self.app.root.ids.screen_manager.transition.direction = direction
+ self.app.root.ids.screen_manager.current = "map_settings_screen"
+ self.app.root.ids.nav_drawer.set_state("closed")
+ self.app.sideband.setstate("app.displaying", self.app.root.ids.screen_manager.current)
+
+ def update_cache_size(dt):
+ size = self.app.sideband.get_map_cache_size()
+ size_str = RNS.prettysize(size)
+ self.map_settings_screen.ids.map_cache_button.text = f"Clear {size_str} map cache"
+ if size > 0.0:
+ self.map_settings_screen.ids.map_cache_button.disabled = False
+ else:
+ self.map_settings_screen.ids.map_cache_button.disabled = True
+ self.map_settings_screen.ids.map_cache_button.text = f"No data in map cache"
+
+ Clock.schedule_once(update_cache_size, 0.35)
+
+ def clear_cache(self, sender=None):
+ yes_button = MDRectangleFlatButton(text="Yes",font_size=dp(18), theme_text_color="Custom", line_color=self.app.color_reject, text_color=self.app.color_reject)
+ no_button = MDRectangleFlatButton(text="No",font_size=dp(18))
+ dialog = MDDialog(title="Clear map cache?", buttons=[ yes_button, no_button ])
+
+ def dl_yes(s):
+ dialog.dismiss()
+ self.app.sideband.clear_map_cache()
+ def cb(dt): self.settings_action()
+ self.map_settings_screen.ids.map_cache_button.disabled = True
+ Clock.schedule_once(cb, 1.2)
+
+ def dl_no(s): dialog.dismiss()
+
+ yes_button.bind(on_release=dl_yes)
+ no_button.bind(on_release=dl_no)
+ dialog.open()
+
+ def create_marker(self, source, telemetry, appearance):
+ try:
+ l = telemetry["location"]
+ a_icon = appearance[0]
+ a_fg = appearance[1]; a_bg = appearance[2]
+ marker = CustomMapMarker(lat=l["latitude"], lon=l["longitude"], icon_bg=a_bg)
+ marker.app = self
+ marker.source_dest = source
+ marker.location_time = l["last_update"]
+ marker.icon = MDMapIconButton(icon=a_icon, icon_color=a_fg,
+ md_bg_color=a_bg, theme_icon_color="Custom",
+ icon_size=dp(32), on_release=self.app.map_display_telemetry)
+ marker.icon._default_icon_pad = dp(16)
+ marker.icon.source_dest = marker.source_dest
+ marker.add_widget(marker.icon)
+
+ ########
+ # marker.badge = MDMapIconButton(
+ # icon="network-strength-2", icon_color=[0,0,0,1],
+ # md_bg_color=[1,1,1,1], theme_icon_color="Custom",
+ # icon_size=dp(18),
+ # )
+ # marker.badge._default_icon_pad = dp(5)
+ # marker.icon.add_widget(marker.badge)
+ ########
+
+ return marker
+
+ except Exception as e:
+ RNS.log("Could not create map marker for "+RNS.prettyhexrep(source)+": "+str(e), RNS.LOG_ERROR)
+ return None
+
+ def create_clustered_marker(self, source, telemetry, appearance):
+ try:
+ l = telemetry["location"]; a_icon = appearance[0]
+ a_fg = appearance[1]; a_bg = appearance[2]
+ clustered_marker = self.clustered_layer.add_marker(l["longitude"], l["latitude"], cls=TelemetryClusterMarker,
+ options={"source_dest": source, "app": self.app,
+ "icon": a_icon, "icon_color": a_fg, "md_bg_color": a_bg,
+ "location_time": l["last_update"]})
+ return clustered_marker
+
+ except Exception as e:
+ RNS.log(f"Could not create clustered map marker for {RNS.prettyhexrep(source)}: {e}", RNS.LOG_ERROR)
+ RNS.trace_exception(e)
+ return None
+
+ def update_clustered_markers(self):
+ earliest = time.time() - self.app.sideband.config["map_history_limit"]
+ telemetry_entries = self.app.sideband.list_telemetry(after=earliest)
+ own_address = self.app.sideband.lxmf_destination.hash
+ changes = False
+
+ if not self.app.sideband.config["map_interfaces"]:
+ self.interface_discovery_hashes = {}
+ self.last_interfaces_update = 0
+ else:
+ if time.time() > self.last_interfaces_update+self.IF_UPDATE_INTERVAL:
+ self.last_interfaces_update = time.time()
+ ifs = self.app.list_discovered_interfaces()
+ for iface in ifs:
+ if "transport" in iface and iface["transport"]:
+ telemetry_source = iface["discovery_hash"]
+ self.interface_discovery_hashes[telemetry_source] = iface
+ if "latitude" in iface and "longitude" in iface and iface["latitude"] and iface["longitude"]:
+ if not telemetry_source in self.clustered_markers:
+ if_appearance = self.app.iface_appearance(iface["type"])
+ telemetry = {"location": {"latitude": iface["latitude"], "longitude": iface["longitude"], "last_update": time.time()}}
+ marker = self.create_clustered_marker(telemetry_source, telemetry, if_appearance)
+ if marker != None:
+ self.clustered_markers[telemetry_source] = marker
+ changes = True
+
+
+ # Add own marker if available
+ retain_own = False
+ own_telemetry = self.app.sideband.get_telemetry()
+ own_appearance = [ self.app.sideband.config["telemetry_icon"],
+ self.app.sideband.config["telemetry_fg"],
+ self.app.sideband.config["telemetry_bg"] ]
+
+ # Skip ignored telemetry entries
+ skip_entries = []
+ if self.app.sideband.config["telemetry_display_trusted_only"]:
+ for telemetry_source in telemetry_entries:
+ try:
+ if not self.app.sideband.is_trusted(telemetry_source): skip_entries.append(telemetry_source)
+ except: pass
+
+ for skip_entry in skip_entries:
+ try: telemetry_entries.pop(skip_entry)
+ except: pass
+
+ try:
+ if own_telemetry != None and "location" in own_telemetry and own_telemetry["location"] != None and own_telemetry["location"]["latitude"] != None and own_telemetry["location"]["longitude"] != None:
+ retain_own = True
+
+ if not own_address in self.clustered_markers:
+ clustered_marker = self.create_clustered_marker(own_address, own_telemetry, own_appearance)
+ if clustered_marker != None:
+ self.clustered_markers[own_address] = clustered_marker
+ changes = True
+ else:
+ marker = self.clustered_markers[own_address]
+ o = own_telemetry["location"]
+ if o["last_update"] > marker.location_time or self.app.own_appearance_changed:
+ clustered_marker = self.clustered_markers[own_address]
+ clustered_marker.location_time = o["last_update"]
+ clustered_marker.location_time = time.time()
+ clustered_marker.lon = o["longitude"]
+ clustered_marker.lat = o["latitude"]
+ clustered_marker.icon = own_appearance[0]
+ clustered_marker.icon_color = own_appearance[1]
+ clustered_marker.md_bg_color = own_appearance[2]
+ clustered_marker.update_props()
+ self.app.own_appearance_changed = False
+ changes = True
+
+ except Exception as e: RNS.log("Error while updating own map marker: "+str(e), RNS.LOG_ERROR)
+
+ try:
+ stale_markers = []
+ for marker in self.clustered_markers:
+ if not marker in telemetry_entries and not marker in self.interface_discovery_hashes:
+ if marker == own_address:
+ if not retain_own: stale_markers.append(marker)
+ else: stale_markers.append(marker)
+
+ for marker in stale_markers:
+ RNS.log("Removing stale clustered marker: "+str(marker), RNS.LOG_DEBUG)
+ try:
+ to_remove = self.clustered_markers[marker]
+ self.clustered_layer.remove_marker(self.clustered_markers[marker])
+ self.clustered_markers.pop(marker)
+ changes = True
+ except Exception as e: RNS.log("Error while removing clustered map marker: "+str(e), RNS.LOG_ERROR)
+
+ except Exception as e: RNS.log("Error while removing stale clustered markers: "+str(e), RNS.LOG_ERROR)
+
+ for telemetry_source in telemetry_entries:
+ try:
+ skip = False
+ if telemetry_source == own_address: skip = True
+ elif telemetry_source in self.clustered_markers:
+ marker = self.clustered_markers[telemetry_source]
+ newest_timestamp = telemetry_entries[telemetry_source][0][0]
+ if newest_timestamp <= marker.location_time: skip = True
+
+ latest_viewable = None
+ if not skip:
+ for telemetry_entry in sorted(telemetry_entries[telemetry_source], key=lambda t: t[0], reverse=True):
+ telemetry_timestamp = telemetry_entry[0]
+ telemetry_data = telemetry_entry[1]
+ t = Telemeter.from_packed(telemetry_data)
+ if t != None:
+ telemetry = t.read_all()
+ if "location" in telemetry and telemetry["location"] != None and telemetry["location"]["latitude"] != None and telemetry["location"]["longitude"] != None:
+ latest_viewable = telemetry
+ break
+
+ # TODO: Telemetry entries with connection map sensor types are skipped for now,
+ # until a proper rendering mechanism is implemented
+ elif "connection_map" in telemetry: break
+
+ if latest_viewable != None:
+ l = latest_viewable["location"]
+ if not telemetry_source in self.clustered_markers:
+ appearance = self.app.sideband.peer_appearance(telemetry_source)
+
+ marker = self.create_clustered_marker(telemetry_source, latest_viewable, appearance)
+ if marker != None:
+ self.clustered_markers[telemetry_source] = marker
+ changes = True
+
+ else:
+ appearance = self.app.sideband.peer_appearance(telemetry_source)
+ clustered_marker = self.clustered_markers[telemetry_source]
+ clustered_marker.location_time = latest_viewable["time"]["utc"]
+ clustered_marker.lat = l["latitude"]
+ clustered_marker.lon = l["longitude"]
+ clustered_marker.icon = appearance[0]
+ clustered_marker.icon_color = appearance[1]
+ clustered_marker.md_bg_color = appearance[2]
+ clustered_marker.update_props()
+ changes = True
+
+ except Exception as e:
+ RNS.log("Error while updating clustered map entry for "+RNS.prettyhexrep(telemetry_source)+": "+str(e), RNS.LOG_ERROR)
+ RNS.trace_exception(e)
+
+ self.app.last_map_update = time.time()
+ if changes:
+ self.clustered_layer.ready = True
+ self.clustered_layer.reposition(rebuild=True)
+ self.map.trigger_update(True)
+
+ def update_markers(self, sender=None):
+ st = time.time()
+ if self.app.sideband.config["map_cluster"]:
+ if len(self.map_markers):
+ for marker in self.map_markers:
+ to_remove = self.map_markers[marker]
+ self.map.remove_marker(to_remove)
+ self.map_markers = {}
+ self.update_clustered_markers()
+ uc = len(self.clustered_markers)
+
+ else:
+ if len(self.clustered_markers):
+ stale_markers = []
+ for marker in self.clustered_markers: stale_markers.append(marker)
+ for marker in stale_markers:
+ RNS.log("Removing clustered marker: "+str(marker), RNS.LOG_DEBUG)
+ try:
+ to_remove = self.clustered_markers[marker]
+ self.clustered_layer.remove_marker(self.clustered_markers[marker])
+ self.clustered_markers.pop(marker)
+ changes = True
+ except Exception as e: RNS.log("Error while removing clustered map marker: "+str(e), RNS.LOG_ERROR)
+
+ self.clustered_layer.ready = True
+ self.clustered_layer.reposition(rebuild=True)
+ self.map.trigger_update(True)
+
+ self.update_markers_legacy(sender=sender)
+ uc = len(self.map_markers)
+
+ RNS.log(f"Updated {uc} map markers in {RNS.prettyshorttime(time.time()-st)}", RNS.LOG_DEBUG)
+
+ def update_markers_legacy(self, sender=None):
+ earliest = time.time() - self.app.sideband.config["map_history_limit"]
+ telemetry_entries = self.app.sideband.list_telemetry(after=earliest)
+ own_address = self.app.sideband.lxmf_destination.hash
+ changes = False
+
+ # Add own marker if available
+ retain_own = False
+ own_telemetry = self.app.sideband.get_telemetry()
+ own_appearance = [ self.app.sideband.config["telemetry_icon"],
+ self.app.sideband.config["telemetry_fg"],
+ self.app.sideband.config["telemetry_bg"] ]
+
+ skip_entries = []
+ if self.app.sideband.config["telemetry_display_trusted_only"]:
+ for telemetry_source in telemetry_entries:
+ try:
+ if not self.app.sideband.is_trusted(telemetry_source):
+ skip_entries.append(telemetry_source)
+ except: pass
+
+ for skip_entry in skip_entries:
+ try: telemetry_entries.pop(skip_entry)
+ except: pass
+
+ try:
+ if own_telemetry != None and "location" in own_telemetry and own_telemetry["location"] != None and own_telemetry["location"]["latitude"] != None and own_telemetry["location"]["longitude"] != None:
+ retain_own = True
+
+ if not own_address in self.map_markers:
+ marker = self.create_marker(own_address, own_telemetry, own_appearance)
+ if marker != None:
+ self.map_markers[own_address] = marker
+ self.map.add_marker(marker)
+ changes = True
+ else:
+ marker = self.map_markers[own_address]
+ o = own_telemetry["location"]
+ if o["last_update"] > marker.location_time or self.app.own_appearance_changed:
+ marker.location_time = o["last_update"]
+ marker.lat = o["latitude"]
+ marker.lon = o["longitude"]
+ marker.icon.icon = own_appearance[0]
+ marker.icon.icon_color = own_appearance[1]
+ marker.icon.md_bg_color = own_appearance[2]
+ self.app.own_appearance_changed = False
+ changes = True
+
+ stale_markers = []
+ for marker in self.map_markers:
+ if not marker in telemetry_entries:
+ if marker == own_address:
+ if not retain_own: stale_markers.append(marker)
+ else: stale_markers.append(marker)
+
+ for marker in stale_markers:
+ RNS.log("Removing stale marker: "+str(marker), RNS.LOG_DEBUG)
+ try:
+ to_remove = self.map_markers[marker]
+ self.map.remove_marker(to_remove)
+ self.map_markers.pop(marker)
+ changes = True
+
+ except Exception as e: RNS.log("Error while removing map marker: "+str(e), RNS.LOG_ERROR)
+
+ except Exception as e: RNS.log("Error while updating own map marker: "+str(e), RNS.LOG_ERROR)
+
+ for telemetry_source in telemetry_entries:
+ try:
+ skip = False
+ if telemetry_source == own_address: skip = True
+ elif telemetry_source in self.map_markers:
+ marker = self.map_markers[telemetry_source]
+ newest_timestamp = telemetry_entries[telemetry_source][0][0]
+ if newest_timestamp <= marker.location_time:
+ skip = True
+
+ latest_viewable = None
+ if not skip:
+ for telemetry_entry in sorted(telemetry_entries[telemetry_source], key=lambda t: t[0], reverse=True):
+ telemetry_timestamp = telemetry_entry[0]
+ telemetry_data = telemetry_entry[1]
+ t = Telemeter.from_packed(telemetry_data)
+ if t != None:
+ telemetry = t.read_all()
+ if "location" in telemetry and telemetry["location"] != None and telemetry["location"]["latitude"] != None and telemetry["location"]["longitude"] != None:
+ latest_viewable = telemetry
+ break
+ elif "connection_map" in telemetry:
+ # TODO: Telemetry entries with connection map sensor types are skipped for now,
+ # until a proper rendering mechanism is implemented
+ break
+
+ if latest_viewable != None:
+ l = latest_viewable["location"]
+ if not telemetry_source in self.map_markers:
+ appearance = self.app.sideband.peer_appearance(telemetry_source)
+ marker = self.create_marker(telemetry_source, latest_viewable, appearance)
+ if marker != None:
+ self.map_markers[telemetry_source] = marker
+ self.map.add_marker(marker)
+ changes = True
+ else:
+ marker = self.map_markers[telemetry_source]
+ marker.location_time = latest_viewable["time"]["utc"]
+ marker.lat = l["latitude"]
+ marker.lon = l["longitude"]
+ appearance = self.app.sideband.peer_appearance(telemetry_source)
+ marker.icon.icon = appearance[0]
+ marker.icon.icon_color = appearance[1]
+ marker.icon.md_bg_color = appearance[2]
+ changes = True
+
+ except Exception as e:
+ RNS.log("Error while updating map entry for "+RNS.prettyhexrep(telemetry_source)+": "+str(e), RNS.LOG_ERROR)
+ RNS.trace_exception(e)
+
+ self.app.last_map_update = time.time()
+ if changes: self.map.trigger_update(True)
+
+class MDMapIconButton(MDIconButton):
+ pass
+
+layout_map_settings_screen = """
+MDScreen:
+ name: "map_settings_screen"
+
+ BoxLayout:
+ orientation: "vertical"
+
+ MDTopAppBar:
+ title: "Map Configuration"
+ anchor_title: "left"
+ elevation: 0
+ left_action_items:
+ [['menu', lambda x: root.app.nav_drawer.set_state("open")]]
+ right_action_items:
+ [
+ ['close', lambda x: root.app.close_sub_map_action(self)],
+ ]
+
+ ScrollView:
+ id: map_settings_scrollview
+
+ MDBoxLayout:
+ orientation: "vertical"
+ size_hint_y: None
+ height: self.minimum_height
+ padding: [dp(28), dp(48), dp(28), dp(16)]
+
+ MDLabel:
+ text: "Configure Map"
+ font_style: "H6"
+
+ MDLabel:
+ id: map_config_info
+ markup: True
+ text: ""
+ size_hint_y: None
+ text_size: self.width, None
+ height: self.texture_size[1]
+
+ MDLabel:
+ markup: True
+ text: "\\n"
+ size_hint_y: None
+ text_size: self.width, None
+ height: self.texture_size[1]
+
+ MDBoxLayout:
+ orientation: "horizontal"
+ padding: [0,0,dp(24),0]
+ size_hint_y: None
+ height: dp(48)
+
+ MDLabel:
+ text: "Cluster markers"
+ font_style: "H6"
+
+ MDSwitch:
+ id: map_cluster
+ pos_hint: {"center_y": 0.3}
+ active: False
+
+ MDBoxLayout:
+ orientation: "horizontal"
+ padding: [0,0,dp(24),0]
+ size_hint_y: None
+ height: dp(48)
+
+ MDLabel:
+ text: "Discovered Interfaces"
+ font_style: "H6"
+
+ MDSwitch:
+ id: map_interfaces
+ pos_hint: {"center_y": 0.3}
+ active: False
+
+ MDBoxLayout:
+ orientation: "horizontal"
+ padding: [0,0,dp(24),0]
+ size_hint_y: None
+ height: dp(48)
+
+ MDLabel:
+ text: "Use online map sources"
+ font_style: "H6"
+
+ MDSwitch:
+ id: map_use_online
+ pos_hint: {"center_y": 0.3}
+ active: False
+
+ MDBoxLayout:
+ orientation: "horizontal"
+ padding: [0,0,dp(24),0]
+ size_hint_y: None
+ height: dp(48)
+
+ MDLabel:
+ text: "Use offline map source"
+ font_style: "H6"
+
+ MDSwitch:
+ id: map_use_offline
+ pos_hint: {"center_y": 0.3}
+ active: False
+
+ MDBoxLayout:
+ orientation: "horizontal"
+ padding: [0,0,dp(24),0]
+ size_hint_y: None
+ height: dp(48)
+
+ MDLabel:
+ id: map_storage_external_label
+ text: "Use external storage path"
+ font_style: "H6"
+
+ MDSwitch:
+ id: map_storage_external
+ pos_hint: {"center_y": 0.3}
+ active: False
+
+ MDBoxLayout:
+ orientation: "vertical"
+ size_hint_y: None
+ spacing: dp(24)
+ height: self.minimum_height
+ padding: [0, dp(24), 0, 0]
+
+ MDRectangleFlatIconButton:
+ id: map_select_button
+ icon: "list-box-outline"
+ text: "Select MBTiles Map"
+ padding: [dp(0), dp(14), dp(0), dp(14)]
+ icon_size: dp(24)
+ font_size: dp(16)
+ size_hint: [1.0, None]
+ on_release: root.delegate.select_file_action(self)
+ disabled: False
+
+ MDRectangleFlatIconButton:
+ id: map_cache_button
+ icon: "map-clock-outline"
+ text: "Clear map cache"
+ padding: [dp(0), dp(14), dp(0), dp(14)]
+ icon_size: dp(24)
+ font_size: dp(16)
+ size_hint: [1.0, None]
+ on_release: root.delegate.clear_cache(self)
+ disabled: False
+"""
+
+layout_map_screen = """
+MDScreen:
+ name: "map_screen"
+
+ BoxLayout:
+ orientation: "vertical"
+
+ MDTopAppBar:
+ title: "Map"
+ anchor_title: "left"
+ elevation: 0
+ left_action_items:
+ [['menu', lambda x: root.app.nav_drawer.set_state("open")]]
+ right_action_items:
+ [
+ #['format-list-bulleted-type', lambda x: root.app.map_object_list(self)], # Object List
+ ['arrow-down-bold-hexagon-outline', lambda x: root.app.telemetry_request_action(self)], # Download telemetry
+ ['upload-lock', lambda x: root.app.telemetry_send_update(self)], # Send telemetry update
+ ['access-point-network', lambda x: root.delegate.interfaces_action(self)],
+ ['layers', lambda x: root.delegate.layers_action(self)],
+ ['wrench-cog', lambda x: root.delegate.settings_action(self)],
+ ['crosshairs-gps', lambda x: root.app.map_own_location_action(self)],
+ ['close', lambda x: root.app.close_any_action(self)],
+ ]
+
+ MDBoxLayout:
+ id: map_layout
+"""
\ No newline at end of file
diff --git a/sbapp/ui/objectdetails.py b/sbapp/ui/objectdetails.py
index 5615ba92..0af7ccc0 100644
--- a/sbapp/ui/objectdetails.py
+++ b/sbapp/ui/objectdetails.py
@@ -11,6 +11,7 @@ from kivy.properties import StringProperty, BooleanProperty
from kivy.effects.scroll import ScrollEffect
from kivy.clock import Clock
from sideband.sense import Telemeter
+from sideband.geo import aamsl_to_altitude
import threading
import webbrowser
@@ -43,6 +44,10 @@ class ObjectDetails():
self.from_conv = False
self.from_objects = False
self.viewing_self = False
+ self.viewing_interface = False
+ self.interface_lat = None
+ self.interface_lon = None
+ self.interface_config = None
self.delete_dialog = None
if not self.app.root.ids.screen_manager.has_screen("object_details_screen"):
@@ -59,14 +64,9 @@ class ObjectDetails():
self.screen.ids.object_details_container.add_widget(self.telemetry_list)
ok_button = MDRectangleFlatButton(text="OK",font_size=dp(18))
- self.info_dialog = MDDialog(
- title="Info",
- text="",
- buttons=[ ok_button ],
- )
+ self.info_dialog = MDDialog(title="Info", text="", buttons=[ ok_button ])
- def dl_ok(s):
- self.info_dialog.dismiss()
+ def dl_ok(s): self.info_dialog.dismiss()
ok_button.bind(on_release=dl_ok)
Clock.schedule_interval(self.reload_job, 2)
@@ -80,15 +80,11 @@ class ObjectDetails():
self.reload_telemetry(notoast=True)
def close_action(self, sender=None):
- if self.from_telemetry:
- self.app.telemetry_action(direction="right")
+ if self.from_telemetry: self.app.telemetry_action(direction="right")
else:
- if self.from_conv:
- self.app.open_conversation(self.object_hash, direction="right")
- elif self.from_objects:
- self.app.objects_action(direction="right")
- else:
- self.app.close_sub_map_action()
+ if self.from_conv: self.app.open_conversation(self.object_hash, direction="right")
+ elif self.from_objects: self.app.objects_action(direction="right")
+ else: self.app.close_sub_map_action()
def confirm_delete_telemetry(self, sender=None):
self.app.sideband.clear_telemetry(self.object_hash)
@@ -106,8 +102,7 @@ class ObjectDetails():
self.delete_dialog.dismiss()
self.confirm_delete_telemetry()
- def cb(dt):
- self.reload_telemetry(notoast=True)
+ def cb(dt): self.reload_telemetry(notoast=True)
Clock.schedule_once(cb, 0.2)
def dl_no(s):
@@ -121,72 +116,104 @@ class ObjectDetails():
def reload_telemetry(self, sender=None, notoast=False):
if self.object_hash != None:
self.set_source(self.object_hash, from_conv=self.from_conv, from_objects=self.from_objects, from_telemetry=self.from_telemetry)
- if not notoast:
- toast("Reloaded telemetry for object")
+ if not notoast: toast("Reloaded telemetry for object")
+
+ def get_interface_data(self, discovery_hash):
+ ifs = self.app.list_discovered_interfaces()
+ if not ifs: return None, None, None, None
+
+ if_map = {e["discovery_hash"]: e for e in ifs}
+ if not discovery_hash in if_map: return None, None, None, None
+ else:
+ iface = if_map[discovery_hash]
+ ts = iface["last_heard"]
+ RNS.log(f"Found interface:\n{iface}")
+ t = Telemeter()
+ if "latitude" in iface and "longitude" in iface and iface["latitude"] and iface["longitude"]:
+ t.sensors["time"].data = {"utc":int(ts)}
+ t.synthesize("location")
+ lat = iface["latitude"]; lon = iface["longitude"]
+ altitude = aamsl_to_altitude(iface["height"], lat, lon) if "height" in iface and iface["height"] else None
+ t.sensors["location"].set_update_time(ts)
+ t.sensors["location"].latitude = lat
+ t.sensors["location"].longitude = lon
+ t.sensors["location"].altitude = altitude
+ t.sensors["location"].stale_time = 12*60*60
+
+ telemetry = [[ts, t.packed()]]
+ return telemetry, iface["name"], self.app.iface_appearance(iface["type"]), iface
def set_source(self, source_dest, from_conv=False, from_objects=False, from_telemetry=False, prefetched=None):
try:
self.object_hash = source_dest
own_address = self.app.sideband.lxmf_destination.hash
telemetry_allowed = self.app.sideband.should_send_telemetry(source_dest)
- if source_dest == own_address:
- self.viewing_self = True
- else:
- self.viewing_self = False
+ if source_dest == own_address: self.viewing_self = True
+ else: self.viewing_self = False
+
+ if_name = None; if_appearance = None; if_data = None
+ if len(self.object_hash) == 32:
+ self.viewing_interface = True
+ prefetched, if_name, if_appearance, if_data = self.get_interface_data(self.object_hash)
+ telemetry_allowed = False
+ self.interface_lat = if_data["latitude"] if "latitude" in if_data else None
+ self.interface_lon = if_data["longitude"] if "longitude" in if_data else None
+ self.interface_config = if_data["config_entry"] if "config_entry" in if_data else None
+ else:
+ self.viewing_interface = False
+ self.interface_lat = None
+ self.interface_lon = None
+ self.interface_config = None
- if from_telemetry:
- self.from_telemetry = True
+ if from_telemetry: self.from_telemetry = True
else:
self.from_telemetry = False
- if from_conv:
- self.from_conv = True
+ if from_conv: self.from_conv = True
else:
self.from_conv = False
- if from_objects:
- self.from_objects = True
- else:
- self.from_objects = False
+ if from_objects: self.from_objects = True
+ else: self.from_objects = False
- if self.viewing_self:
- self.screen.ids.track_button.disabled = True
+ if self.viewing_self: self.screen.ids.track_button.disabled = True
else:
self.screen.ids.track_button.disabled = False
- if self.app.sideband.is_tracking(source_dest):
- self.screen.ids.track_button.text = "Stop Live Tracking"
- else:
- self.screen.ids.track_button.text = "Start Live Tracking"
+ if self.app.sideband.is_tracking(source_dest): self.screen.ids.track_button.text = "Stop Live Tracking"
+ else: self.screen.ids.track_button.text = "Start Live Tracking"
self.coords = None
self.telemetry_list.data = []
- pds = multilingual_markup(escape_markup(str(self.app.sideband.peer_display_name(source_dest))).encode("utf-8")).decode("utf-8")
- appearance = self.app.sideband.peer_appearance(source_dest)
+ pds = if_name or multilingual_markup(escape_markup(str(self.app.sideband.peer_display_name(source_dest))).encode("utf-8")).decode("utf-8")
+ appearance = if_appearance or self.app.sideband.peer_appearance(source_dest)
self.screen.ids.name_label.text = pds
- if source_dest == own_address:
- self.screen.ids.name_label.text = pds+" (this device)"
- elif source_dest == self.app.sideband.config["telemetry_collector"]:
- self.screen.ids.name_label.text = pds+" (collector)"
+ if source_dest == own_address: self.screen.ids.name_label.text = pds+" (this device)"
+ elif source_dest == self.app.sideband.config["telemetry_collector"]: self.screen.ids.name_label.text = pds+" (collector)"
self.screen.ids.coordinates_button.disabled = True
self.screen.ids.object_appearance.icon = appearance[0]
self.screen.ids.object_appearance.icon_color = appearance[1]
self.screen.ids.object_appearance.md_bg_color = appearance[2]
def djob(dt):
- if self.viewing_self:
+ if self.viewing_interface:
self.screen.ids.request_button.disabled = True
self.screen.ids.send_button.disabled = True
+ self.screen.ids.track_button.disabled = True
+ self.screen.ids.telemetry_button.text = "Copy Config"
+ elif self.viewing_self:
+ self.screen.ids.request_button.disabled = True
+ self.screen.ids.send_button.disabled = True
+ self.screen.ids.track_button.disabled = True
+ self.screen.ids.telemetry_button.text = "Copy Telemetry"
else:
self.screen.ids.request_button.disabled = False
- if telemetry_allowed:
- self.screen.ids.send_button.disabled = False
- else:
- self.screen.ids.send_button.disabled = True
+ self.screen.ids.track_button.disabled = False
+ self.screen.ids.telemetry_button.text = "Copy Telemetry"
+ if telemetry_allowed: self.screen.ids.send_button.disabled = False
+ else: self.screen.ids.send_button.disabled = True
- if prefetched != None:
- latest_telemetry = prefetched
- else:
- latest_telemetry = self.app.sideband.peer_telemetry(source_dest, limit=1)
+ if prefetched != None: latest_telemetry = prefetched
+ else: latest_telemetry = self.app.sideband.peer_telemetry(source_dest, limit=1)
if latest_telemetry != None and len(latest_telemetry) > 0:
telemetry_timestamp = latest_telemetry[0][0]
@@ -205,7 +232,7 @@ class ObjectDetails():
self.screen.ids.coordinates_button.disabled = False
Clock.schedule_once(job, 0.01)
- self.telemetry_list.update_source(rendered_telemetry)
+ self.telemetry_list.update_source(rendered_telemetry, if_data=if_data, if_appearance=if_appearance)
def job(dt):
self.screen.ids.telemetry_button.disabled = False
Clock.schedule_once(job, 0.01)
@@ -305,7 +332,8 @@ class ObjectDetails():
Clipboard.copy(str(self.coords or "No data"))
def copy_telemetry(self, sender=None):
- Clipboard.copy(str(self.raw_telemetry or "No data"))
+ if not self.viewing_interface: Clipboard.copy(str(self.raw_telemetry or "No data"))
+ else: Clipboard.copy(str(self.interface_config or "No data"))
class ODView(OneLineIconListItem):
icon = StringProperty()
@@ -317,13 +345,14 @@ class RVDetails(MDRecycleView):
super().__init__()
self.data = []
- def update_source(self, rendered_telemetry=None):
+ def update_source(self, rendered_telemetry=None, if_data=None, if_appearance=None):
try:
if not rendered_telemetry:
rendered_telemetry = []
sort = {
"Information": 5,
+ "Timestamp": 7,
"Physical Link": 10,
"Location": 20,
"Ambient Light": 30,
@@ -344,14 +373,37 @@ class RVDetails(MDRecycleView):
"Tank": 90,
"Fuel": 91,
"Custom": 100,
- "Timestamp": 190,
"Received": 200,
}
- def pass_job(sender=None):
- pass
+ def pass_job(sender=None): pass
self.entries = []
+
+ if self.delegate.viewing_interface:
+ if_type = if_data["type"]
+ sc_str = f", {if_data['status'] if if_data['status'] != 'unknown' else 'availability unknown'}" if "status" in if_data and if_data["status"] else ""
+ tid_str = f", ID [b]<{if_data['transport_id']}>[/b]" if "transport_id" in if_data else ""
+ # if "discovery_hash" in if_data: self.entries.append({"icon": "key-link", "text": f"[b]{RNS.prettyhexrep(if_data['discovery_hash'])}[/b]"})
+ self.entries.append({"icon": if_appearance[0], "text": f"Discovered [b]{if_type}[/b]{sc_str}"})
+ if "transport" in if_data: self.entries.append({"icon": "transit-connection-variant", "text": f"Transport [b]{'enabled' if if_data['transport'] else 'disabled'}[/b]{tid_str if if_data['transport'] else ''}"})
+ # if "transport_id" in if_data: self.entries.append({"icon": "key-link", "text": f"Transport ID [b]<{if_data['transport_id']}>[/b]"})
+ if "network_id" in if_data: self.entries.append({"icon": "key-link", "text": f"Network ID [b]<{if_data['network_id']}>[/b]"})
+ if "discovered" in if_data: self.entries.append({"icon": "clock-check-outline", "text": f"First heard [b]{RNS.prettytime(time.time()-if_data['discovered'], compact=True)}[/b] ago"})
+ if "reachable_on" in if_data and "port" in if_data: self.entries.append({"icon": "connection", "text": f"Reachable on [b]{if_data['reachable_on']}:{if_data['port']}[/b]"})
+ if if_type == "I2PInterface" and "reachable_on" in if_data: self.entries.append({"icon": "connection", "text": f"I2P B32 [b]{if_data['reachable_on']}[/b]"})
+ if "frequency" in if_data: self.entries.append({"icon": "sine-wave", "text": f"Frequency [b]{RNS.prettyfrequency(if_data['frequency'], d=3)}[/b]"})
+ if "bandwidth" in if_data: self.entries.append({"icon": "arrow-expand-horizontal", "text": f"Bandwidth [b]{RNS.prettyfrequency(if_data['bandwidth'], d=3)}[/b]"})
+ if "sf" in if_data: self.entries.append({"icon": "poll", "text": f"[b]Spreading Factor {if_data['sf']}[/b]"})
+ if "cr" in if_data: self.entries.append({"icon": "barcode", "text": f"[b]Coding Rate {if_data['cr']}[/b]"})
+
+ # if "" in if_data: self.entries.append({"icon": "", "text": f"[b]{}[/b]"})
+ # if "" in if_data: self.entries.append({"icon": "", "text": f"[b]{}[/b]"})
+
+ if "hops" in if_data: self.entries.append({"icon": "atom-variant", "text": f"Network distance is [b]{if_data['hops']} hop{'' if if_data['hops'] == 1 else 's'}[/b]", "on_release": pass_job})
+ if "value" in if_data: self.entries.append({"icon": "postage-stamp", "text": f"Stamp value [b]{if_data['value']}[/b]"})
+ if "heard_count" in if_data: self.entries.append({"icon": "counter", "text": f"[b]Heard {if_data['heard_count']}[/b] time{'' if if_data['heard_count'] == 1 else 's'}"})
+
rendered_telemetry.sort(key=lambda s: sort[s["name"]] if s["name"] in sort else 1000)
for s in rendered_telemetry:
try:
@@ -663,17 +715,14 @@ class RVDetails(MDRecycleView):
alt_str = RNS.prettydistance(alt)
formatted_values = f"Coordinates [b]{fcoords}[/b], altitude [b]{alt_str}[/b]"
if speed != None:
- if speed > 0.1:
- speed_formatted_values = f"Speed [b]{speed} Km/h[/b], heading [b]{heading}°[/b]"
- else:
- speed_formatted_values = f"Object is [b]stationary[/b]"
- else:
- speed_formatted_values = None
+ if speed > 0.1: speed_formatted_values = f"Speed [b]{speed} Km/h[/b], heading [b]{heading}°[/b]"
+ else: speed_formatted_values = f"Object is [b]stationary[/b]"
+ else: speed_formatted_values = None
extra_formatted_values = f"Uncertainty [b]{accuracy} meters[/b]"+updated_str
data = {"icon": s["icon"], "text": f"{formatted_values}"}
- extra_entries.append({"icon": "map-marker-question", "text": extra_formatted_values})
+ if not self.delegate.viewing_interface: extra_entries.append({"icon": "map-marker-question", "text": extra_formatted_values})
if speed_formatted_values != None:
extra_entries.append({"icon": "speedometer", "text": speed_formatted_values})
@@ -815,8 +864,7 @@ class RVDetails(MDRecycleView):
if ratchet_id:
self.entries.append({"icon": "lock-check-outline", "text": f"Using ratchet [b]{RNS.prettyhexrep(ratchet_id)}[/b]", "on_release": pass_job})
- except Exception as e:
- RNS.trace_exception(e)
+ except Exception as e: RNS.trace_exception(e)
try:
nh = RNS.Transport.hops_to(self.delegate.object_hash)
@@ -852,21 +900,21 @@ class RVDetails(MDRecycleView):
RNS.trace_exception(e)
try:
- ticket_expires = self.delegate.app.sideband.message_router.get_outbound_ticket_expiry(self.delegate.object_hash)
- stamp_cost = self.delegate.app.sideband.message_router.get_outbound_stamp_cost(self.delegate.object_hash)
- t_str = ""
- if ticket_expires:
- t_str = ", but have ticket"
- if stamp_cost:
- self.entries.append({"icon": "postage-stamp", "text": f"Required stamp cost [b]{stamp_cost}[/b]"+t_str, "on_release": pass_job})
- else:
- self.entries.append({"icon": "postage-stamp", "text": f"No required stamp cost", "on_release": pass_job})
- if ticket_expires:
- valid_for = ticket_expires - time.time()
- self.entries.append({"icon": "ticket-confirmation", "text": f"Delivery ticket valid for [b]{RNS.prettytime(valid_for)}[/b]", "on_release": pass_job})
+ if not self.delegate.viewing_interface:
+ ticket_expires = self.delegate.app.sideband.message_router.get_outbound_ticket_expiry(self.delegate.object_hash)
+ stamp_cost = self.delegate.app.sideband.message_router.get_outbound_stamp_cost(self.delegate.object_hash)
+ t_str = ""
+ if ticket_expires:
+ t_str = ", but have ticket"
+ if stamp_cost:
+ self.entries.append({"icon": "postage-stamp", "text": f"Required stamp cost [b]{stamp_cost}[/b]"+t_str, "on_release": pass_job})
+ else:
+ self.entries.append({"icon": "postage-stamp", "text": f"No required stamp cost", "on_release": pass_job})
+ if ticket_expires:
+ valid_for = ticket_expires - time.time()
+ self.entries.append({"icon": "ticket-confirmation", "text": f"Delivery ticket valid for [b]{RNS.prettytime(valid_for)}[/b]", "on_release": pass_job})
- except Exception as e:
- RNS.trace_exception(e)
+ except Exception as e: RNS.trace_exception(e)
if len(self.entries) == 0:
self.entries.append({"icon": "timeline-question-outline", "text": f"No telemetry available for this device"})
diff --git a/sbapp/ui/telemetry.py b/sbapp/ui/telemetry.py
index 1215bd3b..320ac886 100644
--- a/sbapp/ui/telemetry.py
+++ b/sbapp/ui/telemetry.py
@@ -321,7 +321,7 @@ class Telemetry():
self.screen.ids.telemetry_icon_preview.icon_color = color
self.app.sideband.config["telemetry_fg"] = color
self.app.sideband.save_configuration()
- self.own_appearance_changed = True
+ self.app.own_appearance_changed = True
if hasattr(self, "color_picker") and self.color_picker != None:
self.color_picker.dismiss()
self.color_picker = None
@@ -342,7 +342,7 @@ class Telemetry():
self.screen.ids.telemetry_icon_preview.md_bg_color = color
self.app.sideband.config["telemetry_bg"] = color
self.app.sideband.save_configuration()
- self.own_appearance_changed = True
+ self.app.own_appearance_changed = True
if hasattr(self, "color_picker") and self.color_picker != None:
self.color_picker.dismiss()
self.color_picker = None
@@ -538,7 +538,7 @@ class Telemetry():
self.app.sideband.config["telemetry_icon"] = self.screen.ids.telemetry_icon_preview.icon
self.app.sideband.save_configuration()
- self.own_appearance_changed = True
+ self.app.own_appearance_changed = True
def icons_selected(self, selected=None):
RNS.log("Selected: "+str(selected))
diff --git a/setup.py b/setup.py
index 614f3a66..034c0449 100644
--- a/setup.py
+++ b/setup.py
@@ -121,7 +121,7 @@ setuptools.setup(
install_requires=[
"rns>=1.3.7",
"lxmf>=1.0.1",
- "lxst>=0.4.8",
+ "lxst>=0.5.0",
"kivy>=2.3.0",
"numpy>=2.0.0",
"pillow>=10.2.0",
──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────